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
+409
-49
No files matched your search
@@ -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))}` : ''}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+47
-11
@@ -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', () => {
|
||||
if (socket.data?.matchId) leave(socket.data.matchId);
|
||||
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;
|
||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
||||
return stdout.trim();
|
||||
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, {
|
||||
|
||||
Reference in new issue
Block a user