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.
685 lines
29 KiB
JavaScript
685 lines
29 KiB
JavaScript
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);
|
||
}
|
||
}
|