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:
mregosar committed 2026-10-25 23:24:49 +01:00
1 parent 43ecb8a1ad
commit 3bfe087b7d
13 files changed
+327 -5

No files matched your search

+34
View File
@@ -412,6 +412,40 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
}
});
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
try {
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
await app.manager.poll();
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
// Organisator sieht keine Rollback-Funktion
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
const admin = { token: login.token };
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
const map = app.service.seriesMaps(id)[0].map;
assert.ok(points.every((p) => p.map === map));
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
assert.equal(ok.status, 200);
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
assert.equal(log.length, 1);
assert.match(log[0].message, /zurückgespielt/);
} finally {
await close();
}
});
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
const { call, close } = await setup({ adminPassword: '' });
try {
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { backupPrefix, describeBackup, isValidBackupFile, parseBackupFiles, pointsForMap, trimLiveState } from '../src/rounds.js';
const id = 'Ab_9-xY';
const file = (map, round, s1, s2) => `${backupPrefix(id)}_2026-10-25_18-30-00_${map}_round${round}_score_${s1}_${s2}.txt`;
test('Gültige Sicherungsdateien nur für dieses Match', () => {
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), id), true);
assert.equal(isValidBackupFile(file('de_inferno', 3, 2, 1), 'anderes'), false, 'fremdes Match');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_../../etc/passwd.txt`, id), false, 'Pfad');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_x;quit_round1_score_0_0.txt`, id), false, 'Befehl');
assert.equal(isValidBackupFile(`${backupPrefix(id)}_de_inferno.txt`, id), false, 'ohne Runde');
assert.equal(isValidBackupFile(undefined, id), false);
});
test('Liste aus einer RCON-Antwort auslesen, fremde Dateien ignorieren', () => {
const text = [
'Backups:',
file('de_inferno', 1, 1, 0),
file('de_inferno', 2, 1, 1),
'round_backup_fremd_2026_de_nuke_round4_score_3_1.txt',
file('de_nuke', 1, 1, 0),
].join('\n');
const found = parseBackupFiles(text, id);
assert.equal(found.length, 3);
assert.deepEqual(found[0], { file: file('de_inferno', 1, 1, 0), map: 'de_inferno', round: 1, score: { s1: 1, s2: 0 } });
});
test('Nur die Map der laufenden Serie, neueste Runde zuerst', () => {
const all = parseBackupFiles([file('de_inferno', 1, 1, 0), file('de_inferno', 3, 2, 1), file('de_nuke', 2, 1, 1)].join('\n'), id);
const points = pointsForMap(all, 'de_inferno');
assert.deepEqual(points.map((p) => p.round), [3, 1]);
});
test('Stand nach Rollback: Runden bis zur Sicherung, Punkte neu gerechnet', () => {
const state = {
map: 'de_inferno', status: 'live', warmupEnded: true, score: { A: 3, B: 2 }, winner: 'A', pendingRound: null,
rounds: [
{ number: 1, winner: 'A' }, { number: 2, winner: 'B' }, { number: 3, winner: 'A' },
{ number: 4, winner: 'B' }, { number: 5, winner: 'A' },
],
};
const back = trimLiveState(state, 3);
assert.deepEqual(back.rounds.map((r) => r.number), [1, 2, 3]);
assert.deepEqual(back.score, { A: 2, B: 1 });
assert.equal(back.winner, null);
assert.equal(back.status, 'live');
assert.equal(back.warmupEnded, true, 'Zustand bleibt erhalten');
});
test('Beschreibung eines Dateinamens', () => {
assert.deepEqual(describeBackup('nichts.txt'), { file: 'nichts.txt', map: null, round: null, score: null });
});