Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter

Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
This commit is contained in:
mregosar committed 2026-10-25 16:50:22 +01:00
commit f08297dd0d
27 files changed
+6355

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
/**
* Schnittstelle für die CS2-Serververwaltung.
*
* Jeder Adapter implementiert genau diese vier Operationen. Der ServerManager ruft sie auf;
* Routen und Frontend kennen nur den ServerManager.
*
* start({ matchId, mode, secrets })
* -> { handle, host, port, tvPort }
* Startet (oder reserviert) einen Server. handle ist ein beliebiges JSON-Objekt,
* das ausschließlich im Backend gespeichert wird (z. B. Containername, RCON-Ziel).
* secrets = { rconPassword, serverPassword, tvPassword } werden vom Manager erzeugt.
*
* status(handle)
* -> { state: 'starting' | 'running' | 'stopped' | 'failed', message?, playerCount?, map? }
*
* configure(handle, { map, beforeMapChange, afterMapChange, commands })
* -> void Wirft einen Fehler mit verständlicher Meldung, wenn die Konfiguration scheitert.
*
* stop(handle)
* -> void Darf mehrfach aufgerufen werden.
*
* Optional:
* sendCommands(handle, commands) -> string[] für Aktionen wie mp_warmup_end.
*
* Eigenschaften:
* name Kurzname des Adapters
* simulated true, wenn kein echter Server existiert. Das Frontend zeigt dann niemals
* "läuft" an, sondern kennzeichnet alles als Demo.
*/
import { DemoServerAdapter } from './demoAdapter.js';
import { DockerServerAdapter } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) {
switch (config.serverAdapter) {
case 'demo':
return new DemoServerAdapter(config.demo);
case 'docker':
return new DockerServerAdapter(config.docker, deps);
case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps);
default:
throw new Error(`Unbekannter SERVER_ADAPTER "${config.serverAdapter}" (erlaubt: demo, docker, static)`);
}
}
+58
View File
@@ -0,0 +1,58 @@
// Erzeugt die Konsolenbefehle, mit denen ein laufender CS2-Server für eine Map der Serie konfiguriert wird.
// Alle Werte werden so bereinigt, dass sie keine weiteren Befehle einschleusen können.
export function sanitizeConsoleText(value, maxLength = 32) {
return String(value ?? '')
.replace(/["';\\\r\n\t]/g, '')
.replace(/[^\p{L}\p{N} ._\-+#!?()[\]|]/gu, '')
.trim()
.slice(0, maxLength) || 'Team';
}
/**
* Liefert zwei Befehlsgruppen:
* - beforeMapChange: Spielmodus, Passwörter, CSTV und der Mapwechsel selbst.
* game_type/game_mode und tv_enable greifen erst beim Laden einer Map.
* - afterMapChange: Match-Einstellungen. Diese werden erst nach dem Laden gesetzt,
* weil die gamemode_*.cfg des Spiels sie beim Mapwechsel sonst überschreibt.
*
* @param {object} p
* @param {object} p.match Match-Datensatz (Teamnamen)
* @param {object} p.pool Map-Pool (mode/gameType/gameMode)
* @param {object} p.seriesMap { map, ctTeam: 'A'|'B'|null }
* @param {object} p.secrets { serverPassword, tvPassword }
* @param {object} p.settings config.match
*/
export function buildMapCommands({ match, pool, seriesMap, secrets, settings }) {
const isWingman = pool.mode === 'wingman';
const ctTeam = seriesMap.ctTeam ?? 'A';
const tTeam = ctTeam === 'A' ? 'B' : 'A';
const names = { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) };
const beforeMapChange = [
`game_type ${Number(pool.gameType)}`,
`game_mode ${Number(pool.gameMode)}`,
`sv_password "${secrets.serverPassword}"`,
`tv_enable ${settings.tvEnabled ? 1 : 0}`,
`tv_delay ${Number(settings.tvDelaySeconds)}`,
`tv_password "${secrets.tvPassword}"`,
`changelevel ${seriesMap.map}`,
];
const afterMapChange = [
// mp_teamname_1 = Team, das auf CT startet; mp_teamname_2 = Team, das auf T startet
`mp_teamname_1 "${names[ctTeam]}"`,
`mp_teamname_2 "${names[tTeam]}"`,
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
'mp_match_can_clinch 1',
'mp_autoteambalance 0',
'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
'mp_warmup_pausetimer 1',
...settings.extraCommands,
];
return { beforeMapChange, afterMapChange };
}
+51
View File
@@ -0,0 +1,51 @@
// DEMO-ADAPTER: Es wird KEIN echter CS2-Server gestartet.
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class DemoServerAdapter {
constructor({ startSeconds = 20, failFirstConfigure = false } = {}) {
this.name = 'demo';
this.simulated = true;
this.startMs = startSeconds * 1000;
this.failFirstConfigure = failFirstConfigure;
this.configureCalls = new Map();
}
async start({ matchId }) {
return {
handle: { demo: true, matchId, startedAt: Date.now() },
host: 'demo.invalid',
port: 27015,
tvPort: 27020,
};
}
async status(handle) {
if (handle.stopped) return { state: 'stopped', message: 'Simulation beendet' };
const elapsed = Date.now() - handle.startedAt;
if (elapsed < this.startMs) {
return { state: 'starting', message: `Simulierter Start (${Math.ceil((this.startMs - elapsed) / 1000)} s)` };
}
return { state: 'running', message: 'Simulation – kein echter Server', playerCount: null };
}
async configure(handle, plan) {
await sleep(1500);
const calls = (this.configureCalls.get(handle.matchId) ?? 0) + 1;
this.configureCalls.set(handle.matchId, calls);
if (this.failFirstConfigure && calls === 1) {
throw new Error('Simulierter Konfigurationsfehler (DEMO_FAIL_FIRST_CONFIGURE) – bitte erneut versuchen');
}
handle.map = plan.map;
}
async sendCommands(handle, commands) {
return commands.map(() => '(Demo – nicht ausgeführt)');
}
async stop(handle) {
handle.stopped = true;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
const exec = promisify(execFile);
/**
* Startet pro Match einen eigenen CS2-Container (Standard-Image: cm2network/cs2).
* Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT)
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST).
*
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
* und CSTV-Port (UDP).
*/
export class DockerServerAdapter {
constructor(options, { getUsedPorts }) {
this.name = 'docker';
this.simulated = false;
this.options = options;
this.getUsedPorts = getUsedPorts;
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)');
}
async docker(args, timeout = 60_000) {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
return stdout.trim();
}
allocatePort() {
const used = this.getUsedPorts();
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) return p;
}
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
}
async start({ matchId, mode, secrets }) {
const port = this.allocatePort();
const tvPort = port + 1;
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
SRCDS_TOKEN: this.options.gslt,
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword,
CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10',
CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0',
TV_ENABLE: '1',
TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword,
TV_RELAY_PW: secrets.tvPassword,
STEAMAPPVALIDATE: '0',
};
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
args.push(
'-p', `${port}:${port}/tcp`,
'-p', `${port}:${port}/udp`,
'-p', `${tvPort}:${tvPort}/udp`,
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
);
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
args.push(this.options.image);
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
await this.docker(['rm', '-f', container]).catch(() => {});
// Das erste "docker run" kann das Image laden und dauert dann länger.
await this.docker(args, 15 * 60_000);
return {
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
host: this.options.host,
port,
tvPort,
};
}
target(handle) {
return { host: handle.rconHost, port: handle.port, password: handle.rconPassword };
}
async status(handle) {
let state;
try {
state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
} catch {
return { state: 'stopped', message: 'Container existiert nicht mehr' };
}
if (state === 'exited' || state === 'dead') {
const logs = await this.docker(['logs', '--tail', '5', handle.container]).catch(() => '');
return { state: 'failed', message: `Container beendet. Letzte Ausgabe: ${logs.slice(-300) || '–'}` };
}
if (state !== 'running') return { state: 'starting', message: `Container-Status: ${state}` };
try {
const status = await rconStatus(this.target(handle));
return { state: 'running', playerCount: status.playerCount, map: status.map };
} catch {
const minutes = Math.round((Date.now() - handle.startedAt) / 60_000);
if (minutes >= this.options.startTimeoutMinutes) {
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
}
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' };
}
}
async configure(handle, plan) {
await configureViaRcon(this.target(handle), plan);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
async stop(handle) {
await this.docker(['rm', '-f', handle.container]).catch(() => {});
}
}
+158
View File
@@ -0,0 +1,158 @@
import net from 'node:net';
// Source-RCON-Protokoll (TCP), wie es auch der CS2-Dedicated-Server spricht.
// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
const SERVERDATA_AUTH = 3;
const SERVERDATA_AUTH_RESPONSE = 2;
const SERVERDATA_EXECCOMMAND = 2;
export function encodePacket(id, type, body) {
const bodyBuf = Buffer.from(body, 'utf8');
const size = 4 + 4 + bodyBuf.length + 2;
const buf = Buffer.alloc(4 + size);
buf.writeInt32LE(size, 0);
buf.writeInt32LE(id, 4);
buf.writeInt32LE(type, 8);
bodyBuf.copy(buf, 12);
// zwei abschließende Null-Bytes (Body-Terminator + leerer String) sind bereits durch alloc gesetzt
return buf;
}
/** Zerlegt einen Puffer in vollständige Pakete; gibt Pakete und den Rest zurück. */
export function decodePackets(buffer) {
const packets = [];
let offset = 0;
while (buffer.length - offset >= 4) {
const size = buffer.readInt32LE(offset);
if (size < 10 || size > 1024 * 1024) throw new Error(`Ungültige RCON-Paketgröße ${size}`);
if (buffer.length - offset < 4 + size) break;
const id = buffer.readInt32LE(offset + 4);
const type = buffer.readInt32LE(offset + 8);
const body = buffer.toString('utf8', offset + 12, offset + 4 + size - 2);
packets.push({ id, type, body });
offset += 4 + size;
}
return { packets, rest: buffer.subarray(offset) };
}
export class RconError extends Error {}
export class RconClient {
constructor({ host, port, password, timeoutMs = 5000 }) {
Object.assign(this, { host, port, password, timeoutMs });
this.socket = null;
this.nextId = 1;
this.buffer = Buffer.alloc(0);
this.listeners = new Set();
}
connect() {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: this.host, port: this.port });
const timer = setTimeout(() => {
socket.destroy();
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} – Zeitüberschreitung`));
}, this.timeoutMs);
socket.once('error', (err) => {
clearTimeout(timer);
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} fehlgeschlagen: ${err.code ?? err.message}`));
});
socket.once('connect', async () => {
clearTimeout(timer);
this.socket = socket;
socket.on('data', (chunk) => this.onData(chunk));
socket.on('error', () => {});
try {
await this.authenticate();
resolve(this);
} catch (err) {
this.close();
reject(err);
}
});
});
}
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);
}
authenticate() {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => done(new RconError('RCON-Anmeldung – keine Antwort')), this.timeoutMs);
const listener = (packet) => {
if (packet.type !== SERVERDATA_AUTH_RESPONSE) return;
if (packet.id === -1) done(new RconError('RCON-Passwort wurde abgelehnt'));
else if (packet.id === id) done();
};
const done = (err) => {
clearTimeout(timer);
this.listeners.delete(listener);
err ? reject(err) : resolve();
};
this.listeners.add(listener);
this.socket.write(encodePacket(id, SERVERDATA_AUTH, this.password));
});
}
/** Führt einen Befehl aus und sammelt die (ggf. mehrteilige) Antwort. */
command(cmd, { quietMs = 250 } = {}) {
if (!this.socket) return Promise.reject(new RconError('RCON nicht verbunden'));
const id = this.nextId++;
return new Promise((resolve, reject) => {
let body = '';
let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs);
const listener = (packet) => {
if (packet.id !== id) return;
body += packet.body;
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
};
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => {
clearTimeout(timer);
clearTimeout(quietTimer);
this.listeners.delete(listener);
this.socket?.off('close', onClose);
err ? reject(err) : resolve(body);
};
this.listeners.add(listener);
this.socket.once('close', onClose);
this.socket.write(encodePacket(id, SERVERDATA_EXECCOMMAND, cmd));
});
}
close() {
this.socket?.destroy();
this.socket = null;
}
}
/** Baut eine Verbindung auf, führt Befehle nacheinander aus und schließt wieder. */
export async function runRcon({ host, port, password }, commands, options = {}) {
const client = new RconClient({ host, port, password, timeoutMs: options.timeoutMs });
await client.connect();
try {
const outputs = [];
for (const cmd of commands) outputs.push(await client.command(cmd, options));
return outputs;
} finally {
client.close();
}
}
/** Liest Map und Spielerzahl aus der Ausgabe von "status". Gibt null-Werte zurück, wenn unbekannt. */
export function parseStatus(output) {
const text = String(output ?? '');
const map = /(?:loaded spawngroup\(\s*1\)\s*:\s*SV:\s*\[1:\s*(\w+)|^map\s*:\s*(\w+))/im.exec(text);
const players = /players\s*:\s*(\d+)\s*humans/i.exec(text);
return {
map: map ? map[1] ?? map[2] : null,
playerCount: players ? Number(players[1]) : null,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { parseStatus, runRcon } from './rcon.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Gemeinsamer Konfigurationsablauf für echte Server per RCON:
* Befehle vor dem Mapwechsel senden, warten bis die Map geladen ist, dann Match-Einstellungen setzen.
*/
export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000, pollMs = 3000 } = {}) {
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}`);
}
const deadline = Date.now() + loadTimeoutMs;
let lastError = null;
for (;;) {
await sleep(pollMs);
try {
const [status] = await runRcon(target, ['status']);
const parsed = parseStatus(status);
if (parsed.map === plan.map || (!parsed.map && status.includes(plan.map))) break;
lastError = `aktuell geladen: ${parsed.map ?? 'unbekannt'}`;
} catch (err) {
lastError = err.message;
}
if (Date.now() > deadline) {
throw new Error(`Map ${plan.map} wurde nicht rechtzeitig geladen (${lastError ?? 'keine Antwort'})`);
}
}
try {
await runRcon(target, plan.afterMapChange);
} catch (err) {
throw new Error(`Match-Einstellungen konnten nicht gesetzt werden: ${err.message}`);
}
}
export async function rconStatus(target) {
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
return parseStatus(output);
}
+343
View File
@@ -0,0 +1,343 @@
import { buildMapCommands } from './cs2Config.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } 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;
/**
* 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.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)));
}
/** 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));
}
stopLoops() {
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);
}
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`).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, 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);
try {
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
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) {
this.update(matchId, { status: 'start_failed', message: err.message });
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.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}`);
} 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);
if (match.status === 'server_setup' && row?.status === 'ready') {
this.configure(matchId).catch((err) => this.logger.error(err));
}
}
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;
this.update(matchId, { status: 'configuring', 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,
});
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}`);
return;
}
this.update(matchId, {
status: 'configured',
configured_at: Date.now(),
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 (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) {
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');
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');
try {
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
} catch (err) {
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');
this.service.changed(viewer.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) {
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 });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
}
// ---------- Automatisches Herunterfahren ----------
async reap(now = Date.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;
let lastUse = match.last_activity_at;
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);
} else if (webActive) {
continue;
}
if (now - lastUse > timeoutMs) {
await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`);
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
/**
* Nutzt bereits laufende, selbst betriebene CS2-Server (CS2_STATIC_SERVERS).
* "Start" bedeutet hier: einen freien Server aus dem Pool reservieren und per RCON prüfen.
* "Stopp" gibt ihn wieder frei und setzt ein zufälliges Serverpasswort, damit niemand
* mit alten Zugangsdaten auf dem Server bleibt. Der Prozess selbst wird nicht beendet.
*/
export class StaticPoolServerAdapter {
constructor({ servers }, { getUsedPorts }) {
this.name = 'static';
this.simulated = false;
this.servers = servers;
this.getUsedPorts = getUsedPorts;
if (!servers.length) throw new Error('SERVER_ADAPTER=static benötigt CS2_STATIC_SERVERS');
}
async start() {
const used = this.getUsedPorts();
const free = this.servers.find((s) => !used.has(`${s.host}:${s.port}`));
if (!free) throw new Error('Alle konfigurierten Server sind belegt');
return {
handle: { host: free.host, port: free.port, poolKey: `${free.host}:${free.port}`, rconPassword: free.rconPassword },
host: free.host,
port: free.port,
tvPort: free.tvPort,
};
}
target(handle) {
return { host: handle.host, port: handle.port, password: handle.rconPassword };
}
async status(handle) {
try {
const status = await rconStatus(this.target(handle));
return { state: 'running', playerCount: status.playerCount, map: status.map };
} catch (err) {
return { state: 'starting', message: `Server nicht erreichbar: ${err.message}` };
}
}
async configure(handle, plan) {
await configureViaRcon(this.target(handle), plan);
}
async sendCommands(handle, commands) {
return runRcon(this.target(handle), commands);
}
async stop(handle, { resetPassword } = {}) {
if (!resetPassword) return;
await runRcon(this.target(handle), [`sv_password "${resetPassword}"`, 'tv_stoprecord']).catch(() => {});
}
}