Files
Cs2-Mapveto/server/src/servers/dockerAdapter.js
T

481 lines
19 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';
import { sanitizeConsoleText } from './cs2Config.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;
}
}
/** 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();
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;
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
}
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 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;
}
}
/**
* 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',
// 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}`);
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);
}
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;
this.runDocker = runDocker;
this.options = options;
this.diskCache = new Map();
this.diskInflight = new Map();
this.pull = { 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;
}
/**
* 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();
}
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) 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);
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}