diff --git a/.env.example b/.env.example index 6623c5b..9ba47fc 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,9 @@ CS2_PORT_RANGE_END=27115 # Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles CS2_DOCKER_EXTRA_ARGS= +# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST. +# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh +# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] CS2_START_TIMEOUT_MINUTES=20 DOCKER_BIN=docker diff --git a/README.md b/README.md index b47f7c1..388bf4e 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,24 @@ Voraussetzungen: Container heißen `cs2mv-` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`. +### Mehrere Docker-Hosts + +Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host. + +1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image): + + ```bash + bash deploy/docker-host-einrichten.sh cs2@192.168.178.111 + ``` + +2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile: + + ``` + CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}] + ``` + +Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein. + ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`) Für selbst betriebene, dauerhaft laufende CS2-Server: diff --git a/deploy/docker-host-einrichten.sh b/deploy/docker-host-einrichten.sh new file mode 100644 index 0000000..b25c30b --- /dev/null +++ b/deploy/docker-host-einrichten.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH. +# +# Aufruf: bash deploy/docker-host-einrichten.sh [volume] +# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111 +# +# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert. +set -euo pipefail + +TARGET="${1:?Aufruf: $0 [volume]}" +VOLUME="${2:-cs2-mapveto-gamefiles}" +IMAGE="joedwards32/cs2:latest" +HOST_PART="${TARGET#*@}" + +echo "→ Verbindung zu $TARGET prüfen" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true + +echo "→ Docker prüfen" +if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then + echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2 + exit 1 +fi +ssh "$TARGET" "docker info >/dev/null" + +echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)" +ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1" + +echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)" +ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null" + +echo "→ Image $IMAGE laden" +ssh "$TARGET" "docker pull $IMAGE" + +cat < manager.usedPorts() }); + const adapter = adapterOverride ?? createServerAdapter(config, { + getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()), + }); manager = new ServerManager({ db, service, adapter, config, logger }); manager.init(); service.startVetoTimer(); diff --git a/server/src/config.js b/server/src/config.js index 6efc5dd..ba8b605 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -22,6 +22,32 @@ function bool(name, fallback) { return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); } +function parseDockerHosts(raw) { + if (!raw) return null; + let list; + try { + list = JSON.parse(raw); + } catch { + throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON'); + } + if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag'); + const names = new Set(); + return list.map((h) => { + if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost'); + if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`); + names.add(h.name); + return { + name: String(h.name), + dockerHost: h.dockerHost ?? null, + publicHost: String(h.publicHost), + rconHost: h.rconHost ?? h.publicHost, + portRangeStart: h.portStart == null ? undefined : Number(h.portStart), + portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd), + volume: h.volume ?? undefined, + }; + }); +} + function str(name, fallback = '') { const raw = process.env[name]; return raw === undefined || raw === '' ? fallback : raw; @@ -85,6 +111,9 @@ export function loadConfig(overrides = {}) { volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), + // Optional: mehrere Docker-Hosts als JSON-Liste, z. B. + // [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}] + hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')), }, staticPool: { diff --git a/server/src/servers/adapter.js b/server/src/servers/adapter.js index 6ef0ae7..3611be9 100644 --- a/server/src/servers/adapter.js +++ b/server/src/servers/adapter.js @@ -29,7 +29,7 @@ */ import { DemoServerAdapter } from './demoAdapter.js'; -import { DockerServerAdapter } from './dockerAdapter.js'; +import { DockerHostPool } from './dockerAdapter.js'; import { StaticPoolServerAdapter } from './staticPoolAdapter.js'; export function createServerAdapter(config, deps) { @@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) { case 'demo': return new DemoServerAdapter(config.demo); case 'docker': - return new DockerServerAdapter(config.docker, deps); + return new DockerHostPool(config.docker, deps); case 'static': return new StaticPoolServerAdapter(config.staticPool, deps); default: diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 11904c7..5804f5a 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -37,7 +37,8 @@ export class DockerServerAdapter { 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 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(); } @@ -137,7 +138,14 @@ export class DockerServerAdapter { await this.docker(args, 15 * 60_000); return { - handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword }, + handle: { + container, + hostName: this.options.name, + rconHost: this.options.rconHost, + port, + startedAt: Date.now(), + rconPassword: secrets.rconPassword, + }, host: this.options.host, port, tvPort, @@ -192,3 +200,99 @@ export class DockerServerAdapter { 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; + const hosts = options.hosts ?? [{ + 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, + }; + return { ...options, ...host, host: host.publicHost }; + }); + this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, { + getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()), + runDocker, + })])); + } + + /** Freie Port-Paare auf einem Host. */ + freeSlots(host) { + const used = this.getUsedPorts(host.publicHost); + let free = 0; + for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) { + if (!used.has(p) && !used.has(p + 1)) free += 1; + } + return free; + } + + 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) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`); + return adapter; + } + + 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); + } +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 7052bf7..5a82c8d 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -160,6 +160,16 @@ export class ServerManager { this.service.changed(matchId); } + /** Ports, die auf einem bestimmten Docker-Host belegt sind. */ + usedPortsOn(host) { + const used = new Set(); + for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) { + used.add(r.port); + used.add(r.tv_port); + } + return used; + } + usedPorts() { const used = new Set(); for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) { diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js new file mode 100644 index 0000000..7ee4223 --- /dev/null +++ b/server/test/dockerHostPool.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DockerHostPool } from '../src/servers/dockerAdapter.js'; + +const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' }; + +const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare +const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare + +function options(hosts) { + return { + bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', + portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '', + startTimeoutMinutes: 1, hosts, + }; +} + +/** Docker-Aufrufe merken, Container gelten sofort als fertig. */ +function fakeDocker() { + const calls = []; + const run = async (args) => { + calls.push(args); + if (args[0] === 'inspect') return 'running'; + if (args[0] === 'logs') return 'Starting CS2 Dedicated Server'; + return ''; + }; + return { run, calls }; +} + +/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */ +function pool(hosts, used = {}) { + const docker = fakeDocker(); + const p = new DockerHostPool(options(hosts), { + getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []), + runDocker: docker.run, + }); + for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {}; + return { pool: p, calls: docker.calls }; +} + +test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => { + const { pool: p } = pool(null); + assert.equal(p.hosts.length, 1); + assert.equal(p.hosts[0].publicHost, '10.0.0.9'); +}); + +test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => { + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei + const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets }); + assert.equal(r.handle.hostName, 'b'); + assert.equal(r.host, '10.0.0.2'); +}); + +test('Ein voller Host wird übersprungen', async () => { + const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA }); + const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets }); + assert.equal(r.handle.hostName, 'b'); +}); + +test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => { + const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); + const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i); + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB }); + await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/); +}); + +test('Status, Stopp und Befehle gehen an den Host des Matches', async () => { + const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] }); + const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets }); + calls.length = 0; + await p.stop(r.handle); + assert.deepEqual(calls.at(-1)[0], 'rm'); + assert.equal(calls.at(-1).at(-1), r.handle.container); + // Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt + await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/); +}); + +test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => { + const { pool: p } = pool([hostA, hostB]); + assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a')); +});