Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang
- Socket-Nachrichten werden geprüft, Handlerfehler abgefangen, Abfragen pro Verbindung begrenzt (vorher beendete stream:subscribe mit null ohne Anmeldung das Backend). - Fehlermeldungen von Prozessen ohne Befehlszeile und mit geschwärzten Passwörtern/Tokens. - Logparser wertet nur ganze Systemzeilen nach dem Zeitstempel aus; Chat kann keine Map beenden. - Verspätet ankommende Starts nach Stopp, Neuanforderung oder Löschen bauen ihren Container ab. - RCON: kaputte Pakete trennen nur die Verbindung, Befehle ohne Antwort gelten als Fehler. - Organisatoren bekommen einen Stream-Schlüssel nur für ihr Match statt des globalen STREAM_KEY. - Rate-Limiter mit regelmäßiger Bereinigung und Obergrenze, alte Admin-Sitzungen werden entfernt.
This commit is contained in:
1 parent
35bdfc1f67
commit
e18b8b4430
13 files changed
+406
-46
No files matched your search
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
||||
}
|
||||
const token = randomToken();
|
||||
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);
|
||||
return res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
+4
-4
@@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
|
||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||
router.get('/stream/overview', wrap((req) => {
|
||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||
}
|
||||
return service.streamOverview();
|
||||
// Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
|
||||
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({ matchId: access.matchId });
|
||||
}));
|
||||
|
||||
router.post('/matches', limit('create'), wrap((req) => {
|
||||
|
||||
+18
-7
@@ -9,16 +9,26 @@
|
||||
// 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)
|
||||
|
||||
// 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 = {
|
||||
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
||||
warmupStart: /World triggered "Warmup_Start"/,
|
||||
worldRoundEnd: /World triggered "Round_End"/,
|
||||
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
||||
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
|
||||
warmupStart: /^World triggered "Warmup_Start"$/,
|
||||
worldRoundEnd: /^World triggered "Round_End"$/,
|
||||
teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||
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.
|
||||
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
|
||||
const REASONS = {
|
||||
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 });
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
for (const raw of lines) {
|
||||
const line = logPayload(raw);
|
||||
let m;
|
||||
if ((m = RE.matchStart.exec(line))) {
|
||||
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
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 { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.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);
|
||||
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
|
||||
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;
|
||||
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 –
|
||||
* keine Codes, keine Verbindungsdaten.
|
||||
*/
|
||||
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||
/** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
|
||||
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 active = [];
|
||||
const upcoming = [];
|
||||
@@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter {
|
||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||
// 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
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) {
|
||||
let streamTimer = null;
|
||||
const broadcastStream = () => {
|
||||
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) => {
|
||||
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
|
||||
});
|
||||
|
||||
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.emit('overview', { matches: service.listMatches() });
|
||||
});
|
||||
|
||||
socket.on('stream:subscribe', ({ key } = {}) => {
|
||||
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
||||
on('stream:subscribe', ({ key }) => {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
socket.join('stream-overview');
|
||||
socket.emit('stream:overview', service.streamOverview());
|
||||
socket.leave('stream-overview');
|
||||
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 } = {}) => {
|
||||
if (typeof matchId !== 'string') return;
|
||||
on('match:subscribe', ({ matchId, token, playerToken }) => {
|
||||
if (typeof matchId !== 'string' || matchId.length > 64) return;
|
||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||
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 {
|
||||
service.getMatch(matchId);
|
||||
} catch (err) {
|
||||
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
|
||||
};
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
clearInterval(refill);
|
||||
try {
|
||||
if (socket.data?.matchId) leave(socket.data.matchId);
|
||||
} catch (err) {
|
||||
console.error(`[socket] disconnect: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+28
-6
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
|
||||
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). */
|
||||
export function createRateLimiter({ windowMs, max }) {
|
||||
export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
|
||||
const hits = new Map();
|
||||
return function check(key) {
|
||||
let lastSweep = Date.now();
|
||||
function check(key) {
|
||||
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);
|
||||
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 });
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
check.size = () => hits.size;
|
||||
return check;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
import { sanitizeConsoleText } from './cs2Config.js';
|
||||
import { safeErrorMessage } from '../security.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
@@ -149,8 +150,16 @@ export class DockerServerAdapter {
|
||||
async docker(args, timeout = 60_000) {
|
||||
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||
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 });
|
||||
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()) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 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 { randomPassword } from '../security.js';
|
||||
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||
|
||||
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
|
||||
@@ -208,6 +208,11 @@ export class KnifeServers {
|
||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||
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, {
|
||||
status: 'starting',
|
||||
message: 'Container läuft, Spielserver lädt',
|
||||
@@ -219,8 +224,9 @@ export class KnifeServers {
|
||||
started_at: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
||||
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
||||
const message = safeErrorMessage(err, [row.rcon_password]);
|
||||
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 {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
|
||||
@@ -75,9 +75,17 @@ export class RconClient {
|
||||
|
||||
onData(chunk) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
const { packets, rest } = decodePackets(this.buffer);
|
||||
this.buffer = rest;
|
||||
for (const packet of packets) for (const listener of this.listeners) listener(packet);
|
||||
let decoded;
|
||||
try {
|
||||
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() {
|
||||
@@ -105,15 +113,18 @@ export class RconClient {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
let answered = false;
|
||||
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) => {
|
||||
if (packet.id !== id) return;
|
||||
answered = true;
|
||||
body += packet.body;
|
||||
clearTimeout(quietTimer);
|
||||
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) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(quietTimer);
|
||||
|
||||
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
|
||||
try {
|
||||
await runRcon(target, plan.beforeMapChange);
|
||||
} catch (err) {
|
||||
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
|
||||
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
|
||||
// Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
|
||||
// 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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||
import { randomPassword } from '../security.js';
|
||||
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||
|
||||
export const SERVER_STATUS = {
|
||||
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');
|
||||
|
||||
const match = this.service.getMatch(matchId);
|
||||
const attempt = (previous?.attempts ?? 0) + 1;
|
||||
try {
|
||||
const result = await this.adapter.start({
|
||||
matchId,
|
||||
@@ -350,6 +351,14 @@ export class ServerManager {
|
||||
secrets,
|
||||
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, {
|
||||
handle: JSON.stringify(result.handle),
|
||||
host: result.host,
|
||||
@@ -358,8 +367,9 @@ export class ServerManager {
|
||||
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
||||
});
|
||||
} catch (err) {
|
||||
this.update(matchId, { status: 'start_failed', message: err.message });
|
||||
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
|
||||
const message = safeErrorMessage(err, Object.values(secrets));
|
||||
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 {
|
||||
this.busy.delete(matchId);
|
||||
@@ -536,8 +546,9 @@ export class ServerManager {
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||
} catch (err) {
|
||||
this.update(matchId, { status: 'config_failed', message: err.message });
|
||||
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
|
||||
const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
|
||||
this.update(matchId, { status: 'config_failed', message });
|
||||
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
|
||||
return;
|
||||
}
|
||||
this.update(matchId, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user