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.
307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
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);
|
||
}
|
||
}
|