Files
Cs2-Mapveto/server/src/servers/dockerAdapter.js
T
mregosar 69fb27f44b Admin: Auslastung der Docker-Hosts mit den laufenden Matches
Die Admin-Übersicht zeigt pro Docker-Host belegte und freie Plätze und die
Matches, die dort laufen. Ohne konfigurierte Hosts bleibt die Karte aus.
2026-10-29 14:37:05 +01:00

307 lines
11 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 env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
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,
hostName: this.options.name,
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(() => {});
}
}
/**
* Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren,
* der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
*/
export class DockerHostPool {
constructor(options, { getUsedPorts, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
const hosts = options.hosts ?? [{
name: 'default',
dockerHost: process.env.DOCKER_HOST || null,
publicHost: options.host,
rconHost: options.rconHost,
portRangeStart: options.portRangeStart,
portRangeEnd: options.portRangeEnd,
volume: options.volume,
}];
// Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen
this.hosts = hosts.map((h) => {
const host = {
name: h.name,
dockerHost: h.dockerHost ?? null,
publicHost: h.publicHost ?? options.host,
rconHost: h.rconHost ?? options.rconHost,
portRangeStart: h.portRangeStart ?? options.portRangeStart,
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
volume: h.volume ?? options.volume,
};
return { ...options, ...host, host: host.publicHost };
});
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
runDocker,
})]));
}
/** Freie Port-Paare auf einem Host. */
freeSlots(host) {
const used = this.getUsedPorts(host.publicHost);
let free = 0;
for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) free += 1;
}
return free;
}
/** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */
hostStats() {
return this.hosts.map((h) => {
const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2);
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
});
}
getUsedPorts(publicHost) {
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
}
pickHost() {
const best = this.hosts
.map((h) => ({ host: h, free: this.freeSlots(h) }))
.filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
return best.host;
}
async start(args) {
return this.adapters.get(this.pickHost().name).start(args);
}
adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name);
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
return adapter;
}
async status(handle) {
return this.adapterFor(handle).status(handle);
}
async configure(handle, plan) {
return this.adapterFor(handle).configure(handle, plan);
}
async listRoundBackups(handle, matchId) {
return this.adapterFor(handle).listRoundBackups(handle, matchId);
}
async loadRoundBackup(handle, file) {
return this.adapterFor(handle).loadRoundBackup(handle, file);
}
async sendCommands(handle, commands) {
return this.adapterFor(handle).sendCommands(handle, commands);
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}