Rollback: Runden aus CS2-Sicherungen zurückspielen (nur Admin)
Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers (mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich die Sicherungen der laufenden Map auflisten und zurückspielen. Der Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren haben keinen Zugang zu den Rollback-Routen. Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit keine fremden Dateien oder Befehle in die RCON-Zeile gelangen.
This commit is contained in:
1 parent
43ecb8a1ad
commit
3bfe087b7d
13 files changed
+327
-5
No files matched your search
@@ -1,6 +1,8 @@
|
||||
// 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.
|
||||
|
||||
import { BACKUP_PATTERN } from '../rounds.js';
|
||||
|
||||
export function sanitizeConsoleText(value, maxLength = 32) {
|
||||
return String(value ?? '')
|
||||
.replace(/["';\\\r\n\t]/g, '')
|
||||
@@ -23,7 +25,7 @@ export function sanitizeConsoleText(value, maxLength = 32) {
|
||||
* @param {object} p.secrets { serverPassword, tvPassword }
|
||||
* @param {object} p.settings config.match
|
||||
*/
|
||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl }) {
|
||||
export function buildMapCommands({ match, pool, seriesMap, secrets, settings, logUrl, backupPrefix }) {
|
||||
const isWingman = pool.mode === 'wingman';
|
||||
const ctTeam = seriesMap.ctTeam ?? 'A';
|
||||
const tTeam = ctTeam === 'A' ? 'B' : 'A';
|
||||
@@ -40,6 +42,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
||||
...(logUrl && /^https?:\/\/[\w.:/-]+$/.test(logUrl)
|
||||
? ['log on', 'logaddress_delall_http', `logaddress_add_http "${logUrl}"`]
|
||||
: []),
|
||||
// Rundensicherung: CS2 speichert nach jeder Runde den Spielzustand, damit sich Runden zurückspielen lassen
|
||||
...(backupPrefix ? [
|
||||
`mp_backup_round_file "${backupPrefix}"`,
|
||||
`mp_backup_round_file_pattern "${BACKUP_PATTERN}"`,
|
||||
'mp_backup_round_auto 1',
|
||||
'mp_backup_restore_load_autopause 1',
|
||||
] : []),
|
||||
`changelevel ${seriesMap.map}`,
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// 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.
|
||||
|
||||
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class DemoServerAdapter {
|
||||
@@ -41,6 +43,14 @@ export class DemoServerAdapter {
|
||||
handle.map = plan.map;
|
||||
}
|
||||
|
||||
// Simulierte Sicherungen: eine Datei je Runde der aktuellen Map, damit die Oberfläche testbar ist
|
||||
async listRoundBackups(handle, matchId, map = 'de_demo') {
|
||||
const files = [1, 2, 3].map((r) => `${backupPrefix(matchId)}_01_01_${map}_round${r}_score_${r}_0.txt`);
|
||||
return parseBackupFiles(files.join('\n'), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup() {}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
@@ -174,6 +174,14 @@ export class DockerServerAdapter {
|
||||
await configureViaRcon(this.target(handle), plan);
|
||||
}
|
||||
|
||||
async listRoundBackups(handle, matchId) {
|
||||
return listRoundBackups(this.target(handle), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup(handle, file) {
|
||||
await loadRoundBackup(this.target(handle), file);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseBackupFiles } from '../rounds.js';
|
||||
import { parseStatus, runRcon } from './rcon.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -42,3 +43,19 @@ export async function rconStatus(target) {
|
||||
const [output] = await runRcon(target, ['status'], { timeoutMs: 4000 });
|
||||
return parseStatus(output);
|
||||
}
|
||||
|
||||
/** Sicherungsdateien dieses Matches vom Server holen. */
|
||||
export async function listRoundBackups(target, matchId) {
|
||||
const [out] = await runRcon(target, ['mp_backup_restore_list_files 60'], { timeoutMs: 8000, quietMs: 500 });
|
||||
return parseBackupFiles(out, matchId);
|
||||
}
|
||||
|
||||
/** Sicherung laden. Der Dateiname muss vorher mit isValidBackupFile geprüft sein. */
|
||||
export async function loadRoundBackup(target, file) {
|
||||
// mp_backup_restore_load_autopause wirkt in CS2 nicht zuverlässig, deshalb pausieren wir ausdrücklich
|
||||
await runRcon(target, [
|
||||
'mp_backup_restore_load_autopause 1',
|
||||
`mp_backup_restore_load_file "${file}"`,
|
||||
'mp_pause_match',
|
||||
], { timeoutMs: 15000, quietMs: 800 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.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';
|
||||
|
||||
@@ -86,6 +87,43 @@ export class ServerManager {
|
||||
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) {
|
||||
this.service.getMatch(matchId);
|
||||
@@ -275,6 +313,7 @@ export class ServerManager {
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
|
||||
/**
|
||||
@@ -45,6 +45,14 @@ export class StaticPoolServerAdapter {
|
||||
await configureViaRcon(this.target(handle), plan);
|
||||
}
|
||||
|
||||
async listRoundBackups(handle, matchId) {
|
||||
return listRoundBackups(this.target(handle), matchId);
|
||||
}
|
||||
|
||||
async loadRoundBackup(handle, file) {
|
||||
await loadRoundBackup(this.target(handle), file);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user