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); } }