Files
Cs2-Mapveto/server/src/servers/dockerAdapter.js
T
SPP UserandClaude Haiku 5.5 b9dafbc6ab Rollback: Rundensicherung per Startparameter aktivieren
CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst
Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den
Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen
für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 23:44:40 +01:00

195 lines
7.5 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 { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
const exec = promisify(execFile);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
const UPDATE_DONE_MARKER = 'Starting CS2 Dedicated Server';
/**
* Startet pro Match einen eigenen CS2-Container (Standard-Image: joedwards32/cs2, Nachfolger von cm2network/cs2).
* Benötigt Docker (lokal oder per DOCKER_HOST, z. B. ssh://…) und die Adresse, unter der Spieler
* den Docker-Host erreichen (CS2_PUBLIC_HOST).
*
* Ohne CS2_GSLT laufen die Server im LAN-Modus (sv_lan 1): nur aus dem lokalen Netz erreichbar.
*
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
* und CSTV-Port (UDP).
*
* Alle Container teilen sich das Volume mit den Spieldateien. Jeder prüft beim Start per SteamCMD
* auf Updates; damit nie zwei gleichzeitig schreiben, starten Container nacheinander: der nächste
* erst, wenn der vorige seinen Update-Check abgeschlossen hat.
*/
export class DockerServerAdapter {
constructor(options, { getUsedPorts, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.options = options;
this.getUsedPorts = getUsedPorts;
this.runDocker = runDocker; // nur für Tests
this.lanMode = !options.gslt;
this.startQueue = Promise.resolve();
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
}
async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args);
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(args) {
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
const previous = this.startQueue;
let release;
this.startQueue = new Promise((resolve) => { release = resolve; });
await previous;
let result;
try {
result = await this.launch(args);
} catch (err) {
release();
throw err;
}
this.waitForUpdateCheck(result.handle).finally(release);
return result;
}
/**
* Wartet, bis der Container SteamCMD abgeschlossen hat, beendet ist oder die Startzeit abläuft.
* Fertig ist er, sobald der Spielserver per RCON antwortet (RCON gibt es erst nach SteamCMD) oder
* die Startzeile im Log steht. Nur auf das Log zu schauen reicht nicht: Der Server schreibt danach
* so viel, dass die Zeile schnell aus den letzten Zeilen herausfällt.
*/
async waitForUpdateCheck(handle, { pollMs = 3000 } = {}) {
const deadline = Date.now() + this.options.startTimeoutMinutes * 60_000;
while (Date.now() < deadline) {
try {
const state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
if (state !== 'running' && state !== 'created') return;
if (await this.isReachable(handle)) return;
const logs = await this.docker(['logs', '--tail', '2000', handle.container], 15_000);
if (logs.includes(UPDATE_DONE_MARKER)) return;
} catch {
return; // Container weg – nicht ewig blockieren
}
await sleep(pollMs);
}
}
async isReachable(handle) {
try {
await rconStatus(this.target(handle));
return true;
} catch {
return false;
}
}
async launch({ matchId, mode, secrets }) {
const port = this.allocatePort();
const tvPort = port + 1;
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword,
// Spielerplätze + 1 für CSTV (belegt selbst einen Platz) + 1 Reserve (Admin, Ersatz)
CS2_MAXPLAYERS: mode === 'wingman' ? '6' : '12',
CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0',
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
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 (Update-Prüfung oder Start)' };
}
}
async configure(handle, plan) {
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);
}
async stop(handle) {
await this.docker(['rm', '-f', handle.container]).catch(() => {});
}
}