Files
Cs2-Mapveto/server/src/servers/serverManager.js
T
mregosar e18b8b4430 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.
2026-11-01 20:25:45 +01:00

749 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
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, safeErrorMessage } from '../security.js';
export const SERVER_STATUS = {
starting: 'Server startet',
ready: 'Server bereit – wartet auf Veto',
configuring: 'Server wird konfiguriert',
configured: 'Server konfiguriert',
start_failed: 'Serverstart fehlgeschlagen',
config_failed: 'Konfiguration fehlgeschlagen',
failed: 'Server ausgefallen',
stopped: 'Server gestoppt',
};
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
const SLOW_POLL_MS = 30_000;
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
const KNIFE_RETRIES = 3;
const KNIFE_RETRY_DELAY_MS = 10_000;
/**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
* (start/status/configure/stop) und speichert alle Zugangsdaten ausschließlich in der Datenbank.
*/
export class ServerManager {
constructor({ db, service, adapter, config, logger = console }) {
Object.assign(this, { db, service, adapter, config, logger });
this.busy = new Set();
this.simulator = new DemoMatchSimulator();
this.autoAdvance = new Map();
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
this.knifeAttempts = new Map();
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
this.lastPoll = new Map();
this.timers = [];
}
init() {
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
this.service.on('mapEnded', (e) => this.onMapEnded(e));
}
/**
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
*/
onMapEnded({ matchId, number, decided, hasNext }) {
const seconds = this.config.autoAdvanceSeconds;
if (seconds < 0) return;
clearTimeout(this.autoAdvance.get(matchId));
const finish = Boolean(decided) || !hasNext;
this.service.log(matchId, 'series', finish
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
this.service.changed(matchId);
this.autoAdvance.set(matchId, setTimeout(() => {
this.autoAdvance.delete(matchId);
const match = this.service.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
try {
if (finish) {
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
} else if (this.row(matchId)?.status === 'configured') {
this.service.advanceSeriesFor(matchId);
this.configure(matchId).catch((err) => this.logger.error(err));
}
} catch (err) {
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
}
}, seconds * 1000));
}
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
async resume() {
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN ('starting','configuring')`).all();
for (const row of rows) {
if (row.status === 'starting' && !row.handle) {
this.update(row.match_id, { status: 'start_failed', message: 'Start wurde durch einen Neustart des Backends unterbrochen' });
} else if (row.status === 'configuring') {
this.update(row.match_id, { status: 'ready', message: 'Konfiguration wurde unterbrochen und wird wiederholt' });
this.maybeConfigure(row.match_id);
}
}
}
startLoops() {
this.timers.push(setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000));
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
}
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
async rollbackPoints(viewer) {
this.service.require(viewer, 'organizer');
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
return pointsForMap(all, seriesMap.map);
}
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
async rollback(viewer, file) {
this.service.require(viewer, 'organizer');
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
const point = describeBackup(file, match.id);
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(match.id);
try {
await this.adapter.loadRoundBackup(handle, file);
} catch (err) {
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
} finally {
this.busy.delete(match.id);
}
this.service.rewindLiveScore(match.id, point.round, file);
}
rollbackContext(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
}
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
}
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
async deleteMatch(matchId, reason = 'Match vom Admin gelöscht') {
this.service.getMatch(matchId);
clearTimeout(this.autoAdvance.get(matchId));
this.autoAdvance.delete(matchId);
this.simulator.stop(matchId);
this.knifeAttempts.delete(matchId);
await this.stop(matchId, reason);
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
this.service.emit('changed', matchId);
}
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
async idle(timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (this.busy.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
}
stopLoops() {
this.simulator.stopAll();
for (const t of this.autoAdvance.values()) clearTimeout(t);
this.autoAdvance.clear();
this.timers.forEach(clearInterval);
this.timers = [];
}
row(matchId) {
return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null;
}
update(matchId, fields) {
const entries = { ...fields, updated_at: Date.now() };
const sets = Object.keys(entries).map((k) => `${k} = @${k}`).join(', ');
this.db.prepare(`UPDATE servers SET ${sets} WHERE match_id = @match_id`).run({ ...entries, match_id: matchId });
this.service.changed(matchId);
}
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
async hostUsage() {
if (!this.adapter.hostStats) return [];
const rows = this.db.prepare(`
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
FROM servers s JOIN matches m ON m.id = s.match_id
WHERE s.status != 'stopped' AND s.host IS NOT NULL
`).all();
const knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
const stats = this.adapter.hostStats();
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
return stats.map((h, i) => ({
...h,
disk: disks[i],
matches: [
...rows
.filter((r) => r.host === h.publicHost)
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
...knives
.filter((k) => k.host === h.publicHost)
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
],
}));
}
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
activeServers() {
return this.db.prepare(`
SELECT s.match_id, s.status, s.host, s.handle, m.mode
FROM servers s JOIN matches m ON m.id = s.match_id
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
`).all().map((r) => {
let handle = null;
try {
handle = JSON.parse(r.handle);
} catch {
// kaputter Handle: Server zählt trotzdem als aktiv
}
return { ...r, handle, hostName: handle?.hostName ?? null };
});
}
/**
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
*/
setDockerHosts(hosts) {
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
for (const server of this.activeServers()) {
const name = server.hostName ?? this.adapter.hosts[0]?.name;
const old = before.get(name);
const next = nextList.find((h) => h.name === name);
if (!next) {
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
}
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
}
}
this.adapter.setHosts(hosts);
}
/**
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
* aktualisiert wurden und welche nicht erreichbar waren.
*/
async applyLiveSettings(changed) {
const keys = new Set(changed);
const rows = this.activeServers().filter((r) => r.status === 'configured');
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
const m = this.config.match;
const result = { updated: 0, failed: [] };
for (const row of rows) {
const wingman = row.mode === 'wingman';
const commands = [];
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
}
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
if (commands.length === 0) continue;
try {
await this.adapter.sendCommands(row.handle, commands);
result.updated += 1;
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
} catch (err) {
result.failed.push(row.match_id);
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
}
}
return result;
}
/** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */
startingOn(host, exclude = null) {
return this.db.prepare(`
SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude)
+ (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n
`).get({ host, exclude }).n;
}
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) {
const used = new Set();
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
used.add(r.port);
used.add(r.tv_port);
}
// Messer-Server belegen ihre Ports dauerhaft
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
used.add(r.port);
used.add(r.tv_port);
}
return used;
}
usedPorts() {
const used = new Set();
for (const r of this.db.prepare(`
SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL
`).all()) {
used.add(r.port);
used.add(r.tv_port);
used.add(`${r.host}:${r.port}`);
}
return used;
}
// ---------- Start ----------
async provision(matchId) {
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const previous = this.row(matchId);
const secrets = {
rconPassword: randomPassword(24),
serverPassword: randomPassword(10),
tvPassword: randomPassword(10),
};
const now = Date.now();
this.db
.prepare(
`INSERT INTO servers (match_id, adapter, simulated, status, message, handle, host, port, tv_port,
rcon_password, server_password, tv_password, config_key, attempts, started_at, updated_at)
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
@rcon, @server, @tv, @key, @attempts, @now, @now)
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
player_count = NULL, last_player_seen_at = NULL, updated_at = excluded.updated_at`,
)
.run({
match_id: matchId,
adapter: this.adapter.name,
simulated: this.adapter.simulated ? 1 : 0,
message: this.adapter.simulated ? 'Demo-Modus: es wird kein echter Server gestartet' : 'Server wird angefordert',
rcon: secrets.rconPassword,
server: secrets.serverPassword,
tv: secrets.tvPassword,
key: randomPassword(24),
attempts: (previous?.attempts ?? 0) + 1,
now,
});
this.service.changed(matchId);
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,
mode: match.mode,
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,
port: result.port,
tv_port: result.tvPort,
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
});
} catch (err) {
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);
}
}
// ---------- Status ----------
async poll() {
const rows = this.db
.prepare(`SELECT * FROM servers WHERE status IN ('starting','ready','configured','config_failed') AND handle IS NOT NULL`)
.all();
await Promise.all(rows.map((row) => this.pollOne(row)));
}
async pollOne(row) {
const matchId = row.match_id;
if (this.busy.has(matchId)) return;
const last = this.lastPoll.get(matchId) ?? 0;
if (row.status !== 'starting' && Date.now() - last < SLOW_POLL_MS) return;
this.lastPoll.set(matchId, Date.now());
let status;
try {
status = await this.adapter.status(JSON.parse(row.handle));
} catch (err) {
status = { state: row.status === 'starting' ? 'starting' : 'running', message: `Statusabfrage fehlgeschlagen: ${err.message}` };
}
const current = this.row(matchId);
if (!current || current.status !== row.status) return; // zwischenzeitlich geändert
const fields = {};
if (status.playerCount !== undefined && status.playerCount !== current.player_count) fields.player_count = status.playerCount;
if (status.playerCount > 0) fields.last_player_seen_at = Date.now();
if (status.state === 'running' && current.status === 'starting') {
Object.assign(fields, { status: 'ready', ready_at: Date.now(), message: status.message ?? 'Server ist erreichbar' });
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) bereit' : 'Server ist hochgefahren');
} else if (status.state === 'failed') {
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
this.service.notify('server_error', matchId, {
title: '⚠️ Serverfehler',
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
color: 0xdc2626,
});
} else if (status.state === 'stopped') {
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
} else if (status.message && status.message !== current.message && current.status === 'starting') {
fields.message = status.message;
}
if (Object.keys(fields).length) this.update(matchId, fields);
if (fields.status === 'ready') this.maybeConfigure(matchId);
}
// ---------- Konfiguration ----------
maybeConfigure(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
if (this.wantsKnife(match, row) && !row.knife_at) {
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err));
}
}
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
wantsKnife(match, row) {
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
}
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
async configureKnife(matchId) {
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const row = this.row(matchId);
const match = this.service.getMatch(matchId);
const pool = this.service.poolFor(match);
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
} catch (err) {
// Das erste Laden einer Map dauert nach einer frischen Installation oft länger: von selbst noch einmal versuchen
const attempt = (this.knifeAttempts.get(matchId) ?? 0) + 1;
this.knifeAttempts.set(matchId, attempt);
const again = attempt < KNIFE_RETRIES;
this.update(matchId, {
knife_at: null,
knife_phase: null,
message: again
? `Messerrunde wird erneut eingerichtet (Versuch ${attempt + 1} von ${KNIFE_RETRIES}): ${err.message}`
: `Messerrunde konnte nicht eingerichtet werden: ${err.message}`,
});
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
if (again) this.timers.push(setTimeout(() => this.maybeConfigure(matchId), this.knifeRetryDelayMs));
return;
}
this.knifeAttempts.delete(matchId);
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
} finally {
this.busy.delete(matchId);
}
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
}
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
async knifeAction(viewer, what) {
this.service.require(viewer, 'organizer');
const match = this.service.getMatch(viewer.matchId);
const row = this.row(viewer.matchId);
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
const start = what === 'start';
try {
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, start
? { knife_phase: 'live', message: 'Messerrunde läuft' }
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
}
async configure(matchId) {
const row = this.row(matchId);
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
if (!['ready', 'configured', 'config_failed'].includes(row.status)) {
throw new HttpError(409, `Server kann im Zustand „${SERVER_STATUS[row.status]}" nicht konfiguriert werden`, 'wrong_state');
}
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
this.busy.add(matchId);
try {
const match = this.service.getMatch(matchId);
const pool = this.service.poolFor(match);
const seriesMap = this.service
.seriesMaps(matchId)
.find((m) => m.number === match.current_map_number);
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({
match,
pool,
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
settings: this.config.match,
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
backupPrefix: backupPrefix(matchId),
});
this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number);
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
if (row.knife_at) {
// Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten,
// und ihr Ergebnis würde als Ende der ersten Map gezählt
plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange];
plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
}
try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) {
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, {
status: 'configured',
configured_at: Date.now(),
knife_at: null,
knife_phase: null,
message: this.adapter.simulated
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
: `${mapName} geladen – Aufwärmphase läuft`,
});
this.service.markCurrentMapLive(matchId);
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
} finally {
this.busy.delete(matchId);
}
}
// ---------- Aktionen des Organisators ----------
async retry(viewer) {
this.service.require(viewer, 'organizer');
const matchId = viewer.matchId;
const match = this.service.getMatch(matchId);
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
const row = this.row(matchId);
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
if (!row || ['start_failed', 'failed', 'stopped'].includes(row.status)) {
if (row?.handle) await this.safeStop(row);
this.service.log(matchId, 'server', 'Organisator startet den Server neu');
await this.provision(matchId);
return;
}
if (this.wantsKnife(match, row)) {
this.knifeAttempts.delete(matchId);
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
}
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
this.configure(matchId).catch((err) => this.logger.error(err));
return;
}
throw new HttpError(409, `Im Zustand „${SERVER_STATUS[row.status]}" ist kein erneuter Versuch nötig`, 'wrong_state');
}
async loadNextMap(viewer) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
clearTimeout(this.autoAdvance.get(viewer.matchId));
this.autoAdvance.delete(viewer.matchId);
this.service.advanceSeries(viewer);
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
}
async endWarmup(viewer) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
// Vor dem Befehl markieren: CS2 schickt Match_Start sofort nach mp_warmup_end, oft bevor RCON antwortet.
this.service.markWarmupEnded(viewer.matchId, true);
try {
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
} catch (err) {
this.service.markWarmupEnded(viewer.matchId, false);
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = [];
if (this.config.match.tvEnabled && row.tv_port) {
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
}
this.service.notify('match_start', viewer.matchId, {
title: '🔴 Match läuft',
description: `**${this.service.matchTitle(viewer.matchId)}**`,
color: 0xef4444,
fields,
});
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
this.service.changed(viewer.matchId);
}
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
async setPaused(viewer, paused) {
this.service.require(viewer, 'organizer');
const row = this.row(viewer.matchId);
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
}
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
startDemoSimulation(matchId) {
const match = this.service.getMatch(matchId);
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
if (!seriesMap) return;
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
this.simulator.start(matchId, {
map: seriesMap.map,
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
ctTeam: seriesMap.ct_team ?? 'A',
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
roundMs: this.config.demo.roundSeconds * 1000,
onLines: (lines) => {
try {
this.service.applyLiveLog(matchId, lines);
} catch (err) {
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
this.simulator.stop(matchId);
}
},
});
}
async stopByOrganizer(viewer) {
this.service.require(viewer, 'organizer');
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
}
async safeStop(row) {
try {
await this.adapter.stop(JSON.parse(row.handle), { resetPassword: randomPassword(16) });
} catch (err) {
this.logger.warn(`[server] Stopp für ${row.match_id} fehlgeschlagen: ${err.message}`);
}
}
async stop(matchId, reason) {
this.simulator.stop(matchId);
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
}
// ---------- Automatisches Herunterfahren ----------
/** Lobbys, die länger als LOBBY_MAX_HOURS offen sind (Veto nie gestartet), samt Server löschen. */
async deleteStaleLobbies(now = Date.now()) {
const hours = this.config.lobbyMaxHours;
if (!hours) return 0;
const stale = this.db.prepare("SELECT id FROM matches WHERE status = 'lobby' AND created_at < ?").all(now - hours * 3_600_000);
for (const { id } of stale) {
if (this.busy.has(id)) continue;
this.logger.log?.(`[aufräumen] Lobby ${id} gelöscht (länger als ${hours} h offen)`);
await this.deleteMatch(id, `Lobby länger als ${hours} h offen`).catch((err) => this.logger.error(err));
}
return stale.length;
}
async reap(now = Date.now()) {
this.service.archiveClosed(now);
await this.deleteStaleLobbies(now);
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
for (const row of rows) {
if (this.busy.has(row.match_id)) continue;
const match = this.service.getMatch(row.match_id);
if (CLOSED_STATUSES.includes(match.status)) {
await this.stop(row.match_id, 'Match beendet');
continue;
}
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
await this.stop(row.match_id, `Maximale Laufzeit von ${this.config.maxLifetimeHours} h erreicht`);
continue;
}
const webActive = this.service.presence(row.match_id).size > 0;
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
// Spieler auf dem Server während der Messerrunde: in Benutzung
if (row.knife_at && row.player_count > 0) continue;
if (row.status === 'configured' && row.player_count !== null) {
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
if (row.player_count > 0) continue;
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0, row.started_at ?? 0);
} else if (webActive) {
continue;
}
if (now - lastUse > timeoutMs) {
await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`);
}
}
}
}