Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander

Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 21:54:30 +01:00
1 parent 8996294d7e
commit 59d6dc7177
5 files changed
+178 -22

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ DEMO_START_SECONDS=20
DEMO_FAIL_FIRST_CONFIGURE=false DEMO_FAIL_FIRST_CONFIGURE=false
# --- Docker-Adapter (SERVER_ADAPTER=docker) --- # --- 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= CS2_GSLT=
# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name) # Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name)
CS2_PUBLIC_HOST= CS2_PUBLIC_HOST=
+2 -2
View File
@@ -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_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server | | `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden | | `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_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste | | `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren | | `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: 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 - **Static:** RCON-Passwörter der Server
RCON-Passwörter für Docker-Container erzeugt das Backend selbst. RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
+48 -12
View File
@@ -83,12 +83,16 @@ volumes:
5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen: 5. Darunter bei **Environment variables** auf *+ Add an environment variable* klicken und anlegen:
| Name | Wert | | Name | Beispielwert | Bedeutung |
|---|---| |---|---|---|
| `SRCDS_TOKEN` | dein GSLT | | `SRCDS_TOKEN` | `A1B2C3D4E5F6…` | dein GSLT; leer lassen für LAN-Modus |
| `CS2_RCONPW` | langes, zufälliges Passwort (nur Buchstaben und Ziffern) | | `CS2_RCONPW` | `Kx7fQ2mPz9Lw4tNb` | RCON-Passwort, lang und zufällig |
| `CS2_PW` | beliebiges Serverpasswort | | `CS2_PW` | `Lan2026` | Serverpasswort |
| `TV_PW` | Passwort für CSTV | | `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. 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. 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\<Name>\.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 ```env
SERVER_ADAPTER=docker SERVER_ADAPTER=docker
CS2_GSLT=dein_gslt DOCKER_HOST=ssh://cs2docker
CS2_PUBLIC_HOST=192.168.178.50 # 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_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_IMAGE=joedwards32/cs2:latest 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: 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. - **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.
- 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. - **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.
- Das Backend braucht Zugriff auf den Docker-Daemon (es läuft auf demselben Host bzw. hat `/var/run/docker.sock`). - **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. 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 ## 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.
--- ---
+53 -7
View File
@@ -4,26 +4,39 @@ import { configureViaRcon, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
const exec = promisify(execFile); 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). * 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) * Benötigt Docker (lokal oder per DOCKER_HOST, z. B. ssh://…) und die Adresse, unter der Spieler
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST). * 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) * Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
* und CSTV-Port (UDP). * 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 { export class DockerServerAdapter {
constructor(options, { getUsedPorts }) { constructor(options, { getUsedPorts, runDocker } = {}) {
this.name = 'docker'; this.name = 'docker';
this.simulated = false; this.simulated = false;
this.options = options; this.options = options;
this.getUsedPorts = getUsedPorts; 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.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) { 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 }); const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
return stdout.trim(); 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'); 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 port = this.allocatePort();
const tvPort = port + 1; const tvPort = port + 1;
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase(); const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = { 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_SERVERNAME: `CS2 Mapveto ${matchId}`,
CS2_PORT: String(port), CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword, CS2_RCONPW: secrets.rconPassword,
@@ -104,7 +150,7 @@ export class DockerServerAdapter {
if (minutes >= this.options.startTimeoutMinutes) { if (minutes >= this.options.startTimeoutMinutes) {
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` }; 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)' };
} }
} }
+74
View File
@@ -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);
});