import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { configureViaRcon, 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 { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 }); 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', 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, 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 sendCommands(handle, commands) { return runRcon(this.target(handle), commands); } async stop(handle) { await this.docker(['rm', '-f', handle.container]).catch(() => {}); } }