diff --git a/.env.example b/.env.example index 6116ff5..245cf03 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ DEMO_START_SECONDS=20 DEMO_FAIL_FIRST_CONFIGURE=false # --- Docker-Adapter (SERVER_ADAPTER=docker) --- -# Pflicht: Game Server Login Token für App-ID 730, https://steamcommunity.com/dev/managegameservers +# Game Server Login Token für App-ID 730 (https://steamcommunity.com/dev/managegameservers); leer = LAN-Modus (sv_lan 1) CS2_GSLT= # Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name) CS2_PUBLIC_HOST= diff --git a/README.md b/README.md index f311a6c..01ab237 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die | `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren | | `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server | | `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden | -| `CS2_GSLT` | – | **Pflicht bei Docker:** Game Server Login Token | +| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) | | `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse | | `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste | | `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren | @@ -343,7 +343,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die Benötigte Zugangsdaten für den Echtbetrieb: -- **Docker:** GSLT, Zugriff auf den Docker-Daemon, öffentliche IP bzw. Portfreigaben +- **Docker:** Zugriff auf den Docker-Daemon (lokal oder per `DOCKER_HOST=ssh://…`), Portfreigaben, für Internet-Betrieb ein GSLT (im LAN nicht nötig) - **Static:** RCON-Passwörter der Server RCON-Passwörter für Docker-Container erzeugt das Backend selbst. diff --git a/docs/portainer-cs2.md b/docs/portainer-cs2.md index 064b68e..dbc8b15 100644 --- a/docs/portainer-cs2.md +++ b/docs/portainer-cs2.md @@ -83,12 +83,16 @@ volumes: 5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen: - | Name | Wert | - |---|---| - | `SRCDS_TOKEN` | dein GSLT | - | `CS2_RCONPW` | langes, zufälliges Passwort (nur Buchstaben und Ziffern) | - | `CS2_PW` | beliebiges Serverpasswort | - | `TV_PW` | Passwort für CSTV | + | Name | Beispielwert | Bedeutung | + |---|---|---| + | `SRCDS_TOKEN` | `A1B2C3D4E5F6…` | dein GSLT; leer lassen für LAN-Modus | + | `CS2_RCONPW` | `Kx7fQ2mPz9Lw4tNb` | RCON-Passwort, lang und zufällig | + | `CS2_PW` | `Lan2026` | Serverpasswort | + | `TV_PW` | `Cstv2026` | Passwort für CSTV | + + > **Nur Buchstaben und Ziffern verwenden.** Das Startskript des Images setzt die Werte ungeschützt in einen + > Shell-Befehl ein. Leerzeichen, Klammern, Kommas, Anführungszeichen oder `$` führen dazu, dass der Server + > nicht startet (`syntax error near unexpected token`) und Docker ihn endlos neu startet. So stehen die Zugangsdaten nicht im Stack-Text selbst. @@ -208,19 +212,51 @@ CS2_STATIC_SERVERS=192.168.178.50:27015:PASSWORT:27020,192.168.178.50:27025:PASS Statt dauerhaft laufender Server kann CS2 Mapveto pro Match einen eigenen Container starten und danach wieder entfernen (`SERVER_ADAPTER=docker`). Damit auch dabei nichts neu geladen wird, nutzt der Adapter das Volume **`cs2-mapveto-gamefiles`**. Das ist genau das Volume, das der Stack oben angelegt und befüllt hat. +### 1. Docker-Zugriff von CS2 Mapveto auf den Docker-Host + +Läuft CS2 Mapveto auf einem anderen Rechner als Docker (z. B. Windows-PC → Docker-Host im LAN), steuert es Docker per SSH: + +1. Auf dem Docker-Host den Benutzer in die Docker-Gruppe aufnehmen (einmalig, danach neu anmelden): + ```bash + sudo usermod -aG docker server + ``` +2. Auf dem Rechner mit CS2 Mapveto einen SSH-Eintrag anlegen (Windows: `C:\Users\\.ssh\config`) und die Anmeldung per Schlüssel einrichten: + ``` + Host cs2docker + HostName 192.168.178.111 + User server + ``` +3. Testen, **auf dem Rechner mit CS2 Mapveto**, nicht auf dem Docker-Host: + ```powershell + docker -H ssh://cs2docker ps + ``` + +### 2. `.env` von CS2 Mapveto + ```env SERVER_ADAPTER=docker -CS2_GSLT=dein_gslt -CS2_PUBLIC_HOST=192.168.178.50 +DOCKER_HOST=ssh://cs2docker +# leer = LAN-Modus (sv_lan 1), sonst ein Game Server Login Token +CS2_GSLT= +# Adresse, unter der Spieler den Docker-Host erreichen +CS2_PUBLIC_HOST=192.168.178.111 +# Adresse, unter der CS2 Mapveto RCON erreicht – bei getrennten Rechnern ebenfalls der Docker-Host +CS2_RCON_HOST=192.168.178.111 CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles CS2_DOCKER_IMAGE=joedwards32/cs2:latest +# zwei Ports pro Match (Spiel TCP+UDP, CSTV UDP), in der Firewall freigeben +CS2_PORT_RANGE_START=27100 +CS2_PORT_RANGE_END=27139 +# Container müssen CS2 Mapveto für die Live-Ergebnisse erreichen +LOG_RECEIVER_URL=http://192.168.178.31:3000 ``` Wichtig dabei: -- **Den Stack-Server vorher stoppen**, damit nicht zwei Container gleichzeitig dasselbe Volume aktualisieren. Der Stack dient dann nur zum einmaligen Befüllen und für Updates. -- Für gleichzeitige Matches gilt dasselbe Problem: Alle Match-Container teilen sich das Volume und prüfen beim Start auf Updates. Das ist unkritisch, solange kein Valve-Update ansteht. Kurz nach einem CS2-Patch nur **ein** Match auf einmal starten. -- Das Backend braucht Zugriff auf den Docker-Daemon (es läuft auf demselben Host bzw. hat `/var/run/docker.sock`). +- **Den Stack-Server stoppen**, sobald die Spieldateien geladen sind. Der Stack dient nur zum einmaligen Befüllen und für Updates. Das Volume bleibt beim Stoppen und Löschen des Stacks erhalten. +- **Starts laufen nacheinander:** Jeder Match-Container prüft beim Start per SteamCMD auf Updates. Damit nie zwei Container gleichzeitig das Volume aktualisieren, startet CS2 Mapveto den nächsten erst, wenn der vorige seinen Update-Check abgeschlossen hat. Nach einem CS2-Patch dauert der erste Start dadurch länger, die übrigen warten kurz. +- **Ein GSLT gilt nur für einen laufenden Server.** Ohne Token (LAN-Modus) gibt es diese Grenze nicht. +- **Hardware:** Ein CS2-Server braucht etwa 1–2 CPU-Kerne und 2–3 GB RAM. 6 Kerne und 16 GB reichen für etwa 2–3 gleichzeitige Matches. Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Schritt 1** die einfachere und robustere Wahl. @@ -228,7 +264,7 @@ Für eine LAN-Party mit festen Servern ist der **Static-Adapter mit Stack aus Sc ## LAN-Betrieb ohne GSLT -Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig. +Für reine LAN-Veranstaltungen kann `CS2_LAN=1` gesetzt werden (`sv_lan 1`). Beim Docker-Adapter genügt es, `CS2_GSLT` leer zu lassen. Der Server ist dann nur im lokalen Netz erreichbar und braucht kein Token. Spieler müssen im selben Netz sein. Für Zuschauer über das Internet oder externe Teams ist ein GSLT nötig. --- diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index eb19320..4255a19 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -4,26 +4,39 @@ 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 auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT) - * und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST). + * 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 }) { + 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'); - if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)'); } 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(); } @@ -36,12 +49,45 @@ export class DockerServerAdapter { throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END'); } - async start({ matchId, mode, secrets }) { + 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.container).finally(release); + return result; + } + + /** Wartet, bis der Container SteamCMD abgeschlossen hat, beendet ist oder die Startzeit abläuft. */ + async waitForUpdateCheck(container, { 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}}', container], 15_000); + if (state !== 'running' && state !== 'created') return; + const logs = await this.docker(['logs', '--tail', '200', container], 15_000); + if (logs.includes(UPDATE_DONE_MARKER)) return; + } catch { + return; // Container weg – nicht ewig blockieren + } + await sleep(pollMs); + } + } + + 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 = { - SRCDS_TOKEN: this.options.gslt, + ...(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, @@ -104,7 +150,7 @@ export class DockerServerAdapter { 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 (Download/Update möglich)' }; + return { state: 'starting', message: 'Container läuft, Spielserver lädt (Update-Prüfung oder Start)' }; } } diff --git a/server/test/dockerAdapter.test.js b/server/test/dockerAdapter.test.js new file mode 100644 index 0000000..f39d557 --- /dev/null +++ b/server/test/dockerAdapter.test.js @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DockerServerAdapter } from '../src/servers/dockerAdapter.js'; + +const options = { + bin: 'docker', image: 'joedwards32/cs2:latest', host: '192.168.178.111', rconHost: '192.168.178.111', + portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '', startTimeoutMinutes: 1, +}; +const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' }; + +/** Simuliertes Docker: merkt sich Aufrufe; Container gelten als "Update fertig", sobald freigegeben. */ +function fakeDocker() { + const calls = []; + const updated = new Set(); + const run = async (args) => { + calls.push(args); + const [cmd] = args; + const name = args.at(-1); + if (cmd === 'inspect') return 'running'; + if (cmd === 'logs') return updated.has(name) ? 'Success! App \'730\' already up to date.\nStarting CS2 Dedicated Server' : 'Checking for available updates...'; + return ''; + }; + return { run, calls, finishUpdate: (name) => updated.add(name) }; +} + +const envOf = (args) => Object.fromEntries(args.flatMap((a, i) => (args[i - 1] === '-e' ? [a.split(/=(.*)/s).slice(0, 2)] : []))); + +test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async () => { + const lan = fakeDocker(); + const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: lan.run }); + const r = await a.start({ matchId: 'abc_DEF-1', mode: 'wingman', secrets }); + lan.finishUpdate(r.handle.container); + const runArgs = lan.calls.find((c) => c[0] === 'run'); + const env = envOf(runArgs); + assert.equal(env.CS2_LAN, '1'); + assert.equal(env.SRCDS_TOKEN, undefined); + assert.equal(env.CS2_GAMEMODE, '2'); + assert.equal(env.CS2_PORT, '27100'); + assert.equal(env.TV_PORT, '27101'); + assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated')); + assert.equal(r.host, '192.168.178.111'); + + const tok = fakeDocker(); + const b = new DockerServerAdapter({ ...options, gslt: 'TOKEN1' }, { getUsedPorts: () => new Set(), runDocker: tok.run }); + const r2 = await b.start({ matchId: 'x', mode: 'competitive', secrets }); + tok.finishUpdate(r2.handle.container); + const env2 = envOf(tok.calls.find((c) => c[0] === 'run')); + assert.equal(env2.SRCDS_TOKEN, 'TOKEN1'); + assert.equal(env2.CS2_LAN, '0'); +}); + +test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgeschlossen hat', async () => { + const d = fakeDocker(); + const used = new Set(); + const a = new DockerServerAdapter(options, { getUsedPorts: () => used, runDocker: d.run }); + // Wartezeit zwischen Prüfungen für den Test verkürzen + const original = a.waitForUpdateCheck.bind(a); + a.waitForUpdateCheck = (name) => original(name, { pollMs: 10 }); + + const first = await a.start({ matchId: 'm1', mode: 'competitive', secrets }); + used.add(first.port).add(first.tvPort); + let secondDone = false; + const second = a.start({ matchId: 'm2', mode: 'competitive', secrets }).then((r) => { secondDone = true; return r; }); + + await new Promise((r) => setTimeout(r, 60)); + assert.equal(secondDone, false, 'zweiter Start wartet noch'); + assert.equal(d.calls.filter((c) => c[0] === 'run').length, 1); + + d.finishUpdate(first.handle.container); + const r2 = await second; + assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2); + assert.equal(r2.port, 27102, 'zweiter Container bekommt die nächsten Ports'); + d.finishUpdate(r2.handle.container); +});