Files
Cs2-Mapveto/server/src/servers/dockerAdapter.js
T
mregosar 52c07c88b8 CS2: sv_deltaticks_enforce 0 gegen NETWORK_DISCONNECT_BADDELTATICK
Mit dem Standard 2 trennt CS2 Spieler und CSTV, wenn deren Bestätigungen außer der Reihe kommen. Neue Container starten mit +sv_deltaticks_enforce 0, beim Einrichten jeder Map und auf Messer-Servern setzt die App den Wert zusätzlich per RCON.
2026-11-01 21:44:55 +01:00

685 lines
29 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, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js';
import { safeErrorMessage } from '../security.js';
const exec = promisify(execFile);
/**
* Plant Spiel- und CSTV-Port für einen neuen Server. Der CSTV-Port liegt immer `tvPortOffset` (Standard 100)
* über dem Spielport. Spielports kommen zuerst aus den LAN-Ports (damit der Server im LAN-Reiter des
* Steam-Serverbrowsers auftaucht), danach aus dem normalen Bereich. null = kein Platz.
*/
export function planPorts(host, used) {
const offset = host.tvPortOffset ?? 100;
const free = (p) => !used.has(p) && p <= 65535;
const candidates = [...(host.lanPorts ?? [])];
for (let p = host.portRangeStart; p <= host.portRangeEnd; p += 1) candidates.push(p);
const port = candidates.find((p) => free(p) && free(p + offset));
return port === undefined ? null : { port, tvPort: port + offset };
}
/** Wie viele Server passen noch auf einen Host, wenn bereits `used` belegt ist. */
export function countSlots(host, used) {
const taken = new Set(used);
let count = 0;
for (;;) {
const plan = planPorts(host, taken);
if (!plan) return count;
taken.add(plan.port);
taken.add(plan.tvPort);
count += 1;
}
}
// Ereignisse des Docker-Hosts, auf Deutsch. exec_* (Healthchecks fremder Container) und Mount-Rauschen fallen weg,
// weil sie nichts aussagen und Befehlszeilen mit Zugangsdaten enthalten können.
const EVENT_LABELS = {
container: {
create: 'angelegt', start: 'gestartet', restart: 'neu gestartet', stop: 'gestoppt', kill: 'abgebrochen',
die: 'beendet', destroy: 'entfernt', pause: 'angehalten', unpause: 'fortgesetzt', oom: 'kein Speicher mehr',
'health_status: healthy': 'gesund', 'health_status: unhealthy': 'ungesund',
},
image: { pull: 'geladen', delete: 'gelöscht', untag: 'Markierung entfernt', tag: 'markiert' },
volume: { create: 'angelegt', destroy: 'gelöscht' },
};
const EVENT_TYPES = { container: 'Container', image: 'Image', volume: 'Volume' };
/** Eine JSON-Zeile von `docker events` lesbar machen. null = Ereignis wird nicht angezeigt. */
export function formatDockerEvent(line) {
let e;
try {
e = JSON.parse(line);
} catch {
return null;
}
const label = EVENT_LABELS[e.Type]?.[e.Action];
if (!label) return null;
const attrs = e.Actor?.Attributes ?? {};
const time = new Date((e.time ?? 0) * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const name = attrs.name ?? e.Actor?.ID?.slice(0, 12) ?? '';
const app = attrs['cs2-mapveto'] === '1' ? ' [Mapveto]' : '';
const extra = e.Action === 'die' && attrs.exitCode != null ? ` (Exit-Code ${attrs.exitCode})`
: e.Action === 'kill' && attrs.signal ? ` (Signal ${attrs.signal})`
: e.Type === 'container' && attrs.image ? ` (${attrs.image})` : '';
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
}
/**
* Skript für den Update-Container: dasselbe SteamCMD-Update wie beim Serverstart des Images (entry.sh),
* nur ohne danach den Server zu starten. Vorher und nachher wird die Build-Nummer ausgegeben.
*/
export const GAMEFILES_UPDATE_SCRIPT = [
'f=/home/steam/cs2-dedicated/steamapps/appmanifest_730.acf',
'b() { grep -m1 \'"buildid"\' "$f" 2>/dev/null | tr -dc 0-9; }',
'echo "BUILD_BEFORE=$(b)"',
'bash /home/steam/steamcmd/steamcmd.sh +force_install_dir /home/steam/cs2-dedicated +@bClientTryRequestManifestWithoutCode 1'
+ ' +login anonymous +app_update 730 -beta public +quit',
'rc=$?',
'echo "BUILD_AFTER=$(b)"',
// StateFlags 4 = vollständig installiert; SteamCMD meldet auch bei Abbruch oft Exit-Code 0
'echo "STATE_AFTER=$(grep -m1 \'"StateFlags"\' "$f" 2>/dev/null | tr -dc 0-9)"',
'exit $rc',
].join('; ');
/** Fortschritt und Build-Nummern aus der Ausgabe des Update-Containers lesen. */
export function parseUpdateOutput(text, into = {}) {
for (const line of String(text).split(/\r?\n/)) {
const before = /^BUILD_BEFORE=(\d*)/.exec(line);
const after = /^BUILD_AFTER=(\d*)/.exec(line);
const state = /^STATE_AFTER=(\d*)/.exec(line);
const error = /(Error!.*|ERROR!.*)/.exec(line);
const progress = /progress:\s*([\d.]+)/.exec(line);
if (before) into.buildBefore = before[1] || null;
else if (after) into.buildAfter = after[1] || null;
else if (state) into.stateAfter = state[1] || null;
else if (error) into.error = error[1].trim().slice(0, 200);
else if (progress) into.progress = Number(progress[1]);
if (line.trim()) into.lastLine = line.trim().slice(0, 200);
}
return into;
}
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
const PORT_BUSY = /port is already allocated|address already in use/i;
const DISK_CACHE_MS = 60_000;
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */
export function parseDf(output) {
const parts = String(output).trim().split(/\s+/);
const totalBytes = Number(parts[1]);
const freeBytes = Number(parts[3]);
if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null;
return { totalBytes, freeBytes };
}
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();
// Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt
this.pendingStarts = 0;
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, this.options.name);
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
try {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
} catch (err) {
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
const clean = new Error(safeErrorMessage(err));
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
clean.code = err.code;
throw clean;
}
}
allocatePorts(blocked = new Set()) {
const used = new Set([...this.getUsedPorts(), ...blocked]);
const plan = planPorts(this.options, used);
if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
return plan;
}
/** Servername im Steam-Serverbrowser, z. B. "Maxlan | Team A vs. Team B". */
serverName(matchId, teamNames) {
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
if (!teamNames) return `${prefix} ${matchId}`.trim();
const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`;
return (prefix ? `${prefix} | ${title}` : title).slice(0, 63);
}
async start(args) {
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
const previous = this.startQueue;
let releaseQueue;
this.startQueue = new Promise((resolve) => { releaseQueue = resolve; });
this.pendingStarts += 1;
const release = () => {
this.pendingStarts -= 1;
releaseQueue();
};
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;
}
}
/**
* Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
* nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei.
*/
async hostPortsFree(ports) {
if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true;
try {
const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or ');
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length),
`ss -Hlnut '( ${filter} )'`,
], { timeout: 10_000 });
return stdout.trim() === '';
} catch {
return true;
}
}
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
const { port, tvPort } = this.allocatePorts(blocked);
const lan = (this.options.lanPorts ?? []).includes(port);
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
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: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
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',
// -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
// sv_deltaticks_enforce 0: sonst trennt CS2 Spieler und CSTV mit NETWORK_DISCONNECT_BADDELTATICK
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files +sv_deltaticks_enforce 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}`);
if (lan) {
// Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
// nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
args.push('--network', 'host');
} else {
args.push(
'-p', `${port}:${port}/tcp`,
'-p', `${port}:${port}/udp`,
'-p', `${tvPort}:${tvPort}/udp`,
);
}
args.push('-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.
try {
await this.docker(args, 15 * 60_000);
} catch (err) {
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
await this.docker(['rm', '-f', container]).catch(() => {});
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
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);
}
/** Ereignisse des Docker-Hosts live (`docker events`), ab `sinceMinutes` in der Vergangenheit. Nur lesen. */
streamEvents({ sinceMinutes = 120 } = {}) {
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
return spawn(this.options.bin, ['events', '--since', `${Math.max(0, sinceMinutes)}m`, '--format', '{{json .}}'], { env, windowsHide: true });
}
/** Konsolenausgabe des Containers live: `docker logs --follow`. Liefert den Kindprozess (stdout/stderr, kill). */
streamLogs(handle, { tail = 200 } = {}) {
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
return spawn(this.options.bin, ['logs', '--follow', '--tail', String(tail), handle.container], { env, windowsHide: true });
}
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, getStarting, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
this.depsGetStarting = getStarting;
this.runDocker = runDocker;
this.options = options;
this.diskCache = new Map();
this.diskInflight = new Map();
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
this.files = { running: false, startedAt: null, finishedAt: null, results: [] };
this.setHosts(options.hosts);
}
/** Baut die Host-Liste neu auf (beim Start und wenn der Admin die Hosts ändert). */
setHosts(list) {
const options = this.options;
const hosts = list ?? [{
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,
lanPorts: h.lanPorts ?? options.lanPorts ?? [],
tvPortOffset: options.tvPortOffset ?? 100,
};
const merged = { ...options, ...host, host: host.publicHost };
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
return merged;
});
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
runDocker: this.runDocker,
})]));
this.diskCache.clear();
}
/**
* Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
* nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
*/
async hostDocker(name, args, timeout) {
const host = this.hosts.find((h) => h.name === name);
if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
['docker', ...args].map((a) => `'${a}'`).join(' '),
], { timeout, maxBuffer: 4 * 1024 * 1024 });
return stdout.trim();
}
return this.adapters.get(name).docker(args, timeout);
}
/** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
async pullImage() {
if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
const hosts = this.hosts.map((h) => h.name);
this.pull = {
running: true,
startedAt: Date.now(),
finishedAt: null,
image: this.options.image,
results: hosts.map((name) => ({ name, state: 'running', message: null })),
};
await Promise.all(hosts.map(async (name, i) => {
const result = this.pull.results[i];
try {
await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
.catch(() => '');
Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
} catch (err) {
Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
}
}));
this.pull.running = false;
this.pull.finishedAt = Date.now();
return this.pull;
}
pullStatus() {
return this.pull;
}
/**
* CS2-Spieldateien im Volume jedes Hosts per SteamCMD aktualisieren (eigener, kurzlebiger Container).
* Hosts in `skip` (z. B. mit laufenden Servern) werden ausgelassen, damit niemandem die Dateien unter
* einem laufenden Server ausgetauscht werden.
*/
async updateGameFiles({ skip = new Map() } = {}) {
if (this.files.running) throw new Error('Die Spieldateien werden bereits aktualisiert');
const hosts = this.hosts.map((h) => h.name);
this.files = {
running: true,
startedAt: Date.now(),
finishedAt: null,
results: hosts.map((name) => (skip.has(name)
? { name, state: 'skipped', message: skip.get(name) }
: { name, state: 'running', message: 'SteamCMD prüft auf Updates …', progress: null })),
};
await Promise.all(this.files.results.filter((r) => r.state === 'running').map((r) => this.runFileUpdate(r)));
this.files.running = false;
this.files.finishedAt = Date.now();
return this.files;
}
async runFileUpdate(result) {
const host = this.hosts.find((h) => h.name === result.name);
const args = [
'run', '--rm', '--name', `cs2mv-update-${host.name.replace(/[^a-zA-Z0-9_.-]/g, '')}`, '--label', 'cs2-mapveto-update=1',
'-v', `${host.volume}:/home/steam/cs2-dedicated`, '--entrypoint', 'bash', this.options.image, '-c', GAMEFILES_UPDATE_SCRIPT,
];
const info = {};
const finish = (exitOk, error) => {
const { buildBefore: from, buildAfter: to } = info;
// Nur "ok", wenn Steam die App als vollständig installiert meldet
const complete = info.stateAfter == null || info.stateAfter === '4';
const ok = exitOk && complete && !info.error;
if (!ok && exitOk && !error) {
error = info.error ?? `nicht vollständig installiert (StateFlags ${info.stateAfter}), bitte erneut starten`;
}
if (ok) {
Object.assign(result, {
state: 'ok',
progress: 100,
message: from && to && from === to ? `bereits aktuell (Build ${to})` : to ? `aktualisiert auf Build ${to}${from ? ` (vorher ${from})` : ''}` : 'aktualisiert',
});
} else {
Object.assign(result, { state: 'failed', message: String(error || info.lastLine || 'SteamCMD-Update fehlgeschlagen').slice(0, 200) });
}
};
if (this.runDocker) {
// Tests: Ausgabe kommt gesammelt zurück
try {
parseUpdateOutput(await this.runDocker(args, host.name), info);
finish(true);
} catch (err) {
finish(false, err.message);
}
return;
}
await new Promise((resolve) => {
const env = host.dockerHost ? { ...process.env, DOCKER_HOST: host.dockerHost } : process.env;
const proc = spawn(this.options.bin, args, { env, windowsHide: true });
const onData = (chunk) => {
parseUpdateOutput(chunk.toString('utf8'), info);
if (info.progress != null) Object.assign(result, { progress: info.progress, message: `Update läuft ${Math.floor(info.progress)} %` });
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
// Ein großes Update dauert, aber nicht ewig
const timer = setTimeout(() => proc.kill(), 90 * 60_000);
proc.on('error', (err) => { clearTimeout(timer); finish(false, err.message); resolve(); });
proc.on('close', (code) => { clearTimeout(timer); finish(code === 0, code === null ? 'abgebrochen (Zeitlimit)' : null); resolve(); });
});
}
filesStatus() {
return this.files;
}
/**
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
*/
async diskUsage(name) {
const host = this.hosts.find((h) => h.name === name);
if (!host?.dockerHost?.startsWith('ssh://')) return null;
const cached = this.diskCache.get(name);
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
if (!this.diskInflight.has(name)) {
this.diskInflight.set(name, (async () => {
let value = null;
try {
const { stdout } = await exec('ssh', [
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
`df -B1 "$(docker volume inspect ${host.volume} --format '{{with .Options}}{{.device}}{{end}}' 2>/dev/null | grep . || docker volume inspect ${host.volume} --format '{{.Mountpoint}}' 2>/dev/null || echo /var/lib/docker)" 2>/dev/null || df -B1 /var/lib/docker 2>/dev/null || df -B1 /`,
], { timeout: 10_000 });
value = parseDf(stdout.trim().split('\n').pop());
} catch {
value = null;
}
this.diskCache.set(name, { at: Date.now(), value });
this.diskInflight.delete(name);
return value;
})());
}
return this.diskInflight.get(name);
}
/** Wie viele Server noch auf einen Host passen. */
freeSlots(host) {
return countSlots(host, this.getUsedPorts(host.publicHost));
}
/** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */
hostStats() {
return this.hosts.map((h) => {
const total = countSlots(h, new Set());
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
});
}
getUsedPorts(publicHost) {
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
}
/** Hosts, auf denen gerade die Spieldateien aktualisiert werden: dort startet kein neuer Server. */
updatingHosts() {
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
}
/**
* Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und
* (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start
* per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten.
*/
startingOn(host, exclude) {
const queued = this.adapters.get(host.name)?.pendingStarts ?? 0;
const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0;
return Math.max(queued, stored);
}
pickHost(exclude) {
const updating = this.updatingHosts();
const best = this.hosts
.filter((h) => !updating.has(h.name))
.map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) }))
.filter((x) => x.free > 0)
// Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen
.sort((a, b) => a.starting - b.starting || b.free - a.free)[0];
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
return best.host;
}
async start(args) {
return this.adapters.get(this.pickHost(args?.matchId).name).start(args);
}
adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name);
if (adapter) return adapter;
// Server aus der Zeit vor der Host-Liste (Name "default") oder nach einer Umbenennung: über die Adresse finden
const byAddress = handle?.rconHost && this.hosts.find((h) => h.rconHost === handle.rconHost || h.publicHost === handle.rconHost);
if (byAddress) return this.adapters.get(byAddress.name);
throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
}
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);
}
streamLogs(handle, options) {
return this.adapterFor(handle).streamLogs(handle, options);
}
/** Ereignisse eines Hosts nach Namen; null, wenn es den Host nicht gibt. */
streamEvents(name, options) {
return this.adapters.get(name)?.streamEvents(options) ?? null;
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}