diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cae94c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +client/dist +data +*.db +*.db-* +.env +.env.* +!.env.example +.git +.claude +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..427e451 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# CS2 Mapveto als ein Container: Webseite, Backend, Datenbank (SQLite) und Docker-Client, +# mit dem die App pro Match eigene CS2-Server-Container auf demselben Docker-Host startet. + +# ---------- Bauen ---------- +FROM node:22-bookworm-slim AS build +WORKDIR /app +# Werkzeuge, falls better-sqlite3 kein fertiges Binärpaket findet +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +COPY client/package.json client/ +COPY server/package.json server/ +RUN npm ci +COPY . . +RUN npm run build && npm test && npm prune --omit=dev + +# ---------- Laufzeit ---------- +FROM node:22-bookworm-slim +# Docker-Client (nur das Programm, kein Daemon): spricht über /var/run/docker.sock mit dem Host +COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + DATABASE_FILE=/data/cs2-mapveto.db +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/server ./server +COPY --from=build /app/client/dist ./client/dist +VOLUME /data +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/config').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server/src/index.js"] diff --git a/README.md b/README.md index 01ab237..ae2af15 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Tea ## Schnellstart +**Als Container (empfohlen für den Betrieb):** ein Container mit Webseite, Backend und Datenbank, der die +CS2-Server-Container auf demselben Docker-Host startet. Siehe [docs/container.md](docs/container.md): + +```powershell +docker -H ssh://cs2docker build -t cs2-mapveto:latest . +``` + +…dann den Stack aus der Anleitung in Portainer anlegen. + +**Zum Entwickeln:** + ```bash npm install cp .env.example .env # optional, Standardwerte funktionieren für die Demo diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..74adcfc --- /dev/null +++ b/compose.yml @@ -0,0 +1,41 @@ +# CS2 Mapveto auf einem Docker-Host (z. B. als Portainer-Stack). +# Die App startet pro Match einen CS2-Container auf demselben Host (Docker-Adapter). +# +# Vorher einmalig: Image bauen (siehe docs/container.md) und das Volume +# "cs2-mapveto-gamefiles" mit den CS2-Spieldateien befüllen (docs/portainer-cs2.md). + +services: + cs2-mapveto: + image: cs2-mapveto:latest + build: . + container_name: cs2-mapveto + restart: unless-stopped + ports: + - "3000:3000" + environment: + # Adresse dieses Docker-Hosts im LAN – für Einladungslinks, Spieler-Verbindungen und Live-Logs + - PUBLIC_URL=http://${HOST_IP:-192.168.178.111}:3000 + - LOG_RECEIVER_URL=http://${HOST_IP:-192.168.178.111}:3000 + - SERVER_ADAPTER=docker + - CS2_PUBLIC_HOST=${HOST_IP:-192.168.178.111} + # RCON zu den Match-Containern über die veröffentlichten Ports des Hosts + - CS2_RCON_HOST=${HOST_IP:-192.168.178.111} + # leer = LAN-Modus (sv_lan 1) + - CS2_GSLT=${CS2_GSLT:-} + - CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles + - CS2_DOCKER_IMAGE=joedwards32/cs2:latest + - CS2_PORT_RANGE_START=27100 + - CS2_PORT_RANGE_END=27139 + - CREATE_MATCH_PASSWORD=${CREATE_MATCH_PASSWORD:-} + - STREAM_KEY=${STREAM_KEY:-} + - SERIES_AUTO_ADVANCE_SECONDS=30 + - SERVER_IDLE_TIMEOUT_MINUTES=30 + volumes: + # Datenbank (Matches, Ergebnisse) überdauert Updates des Containers + - cs2-mapveto-data:/data + # Zugriff auf Docker, um Match-Container zu starten und zu entfernen + - /var/run/docker.sock:/var/run/docker.sock + +volumes: + cs2-mapveto-data: + name: cs2-mapveto-data diff --git a/docs/container.md b/docs/container.md new file mode 100644 index 0000000..2cfe152 --- /dev/null +++ b/docs/container.md @@ -0,0 +1,121 @@ +# CS2 Mapveto als Container betreiben + +CS2 Mapveto läuft als **ein** Container: Webseite, Backend, Datenbank (SQLite) und ein Docker-Client. +Er steht auf demselben Docker-Host wie die CS2-Server und startet pro Match über `/var/run/docker.sock` +einen eigenen CS2-Container (Docker-Adapter). Ein PC mit Node.js oder SSH wird dafür nicht mehr gebraucht. + +``` +Docker-Host (z. B. amp-cluster, 192.168.178.111) +├── cs2-mapveto ← dieser Container, Port 3000 +├── cs2mv- ← CS2-Server pro Match, Ports ab 27100 (startet/entfernt CS2 Mapveto) +├── Volume cs2-mapveto-gamefiles CS2-Spieldateien (~73 GB, einmal befüllt) +└── Volume cs2-mapveto-data Datenbank von CS2 Mapveto +``` + +> **Nur eine Instanz pro Docker-Host.** Läuft CS2 Mapveto zusätzlich auf einem PC mit `SERVER_ADAPTER=docker` +> und demselben Docker-Host, vergeben beide unabhängig voneinander Ports und Containernamen. Die PC-Instanz +> also beenden oder auf einen anderen Adapter stellen, bevor der Container läuft. + +--- + +## 1. Image bauen + +Das Image wird auf dem Docker-Host gebaut, beim Bauen laufen auch alle Tests. Am einfachsten vom PC aus, im +Projektordner, über den SSH-Zugang zum Host: + +```powershell +docker -H ssh://cs2docker build -t cs2-mapveto:latest . +``` + +Ergebnis: Image `cs2-mapveto:latest` (~300 MB) auf dem Host. Alternativ direkt auf dem Host nach einem +`git clone` des Repositorys: `docker build -t cs2-mapveto:latest .` + +> Meldet Docker unter Windows `docker-credential-desktop: executable file not found`, kommt das von einer +> Docker-Desktop-Einstellung. Abhilfe für den einen Befehl: `$env:DOCKER_CONFIG = "$env:TEMP\dockercfg"` +> (Ordner mit einer Datei `config.json`, Inhalt `{}`). + +## 2. Stack in Portainer anlegen + +Portainer → **Stacks** → **+ Add stack** → Name `cs2-mapveto` → *Web editor*: + +```yaml +services: + cs2-mapveto: + image: cs2-mapveto:latest + container_name: cs2-mapveto + restart: unless-stopped + ports: + - "3000:3000" + environment: + - PUBLIC_URL=http://192.168.178.111:3000 + - LOG_RECEIVER_URL=http://192.168.178.111:3000 + - SERVER_ADAPTER=docker + - CS2_PUBLIC_HOST=192.168.178.111 + - CS2_RCON_HOST=192.168.178.111 + - CS2_GSLT= + - CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles + - CS2_DOCKER_IMAGE=joedwards32/cs2:latest + - CS2_PORT_RANGE_START=27100 + - CS2_PORT_RANGE_END=27139 + - CREATE_MATCH_PASSWORD= + - STREAM_KEY= + - SERIES_AUTO_ADVANCE_SECONDS=30 + - SERVER_IDLE_TIMEOUT_MINUTES=30 + volumes: + - cs2-mapveto-data:/data + - /var/run/docker.sock:/var/run/docker.sock + +volumes: + cs2-mapveto-data: + name: cs2-mapveto-data +``` + +**Deploy the stack.** Danach ist CS2 Mapveto unter `http://192.168.178.111:3000` erreichbar, die Stream-Übersicht +unter `/stream`. + +Dieselbe Datei liegt als [`compose.yml`](../compose.yml) im Repository (dort zusätzlich mit `build: .`, für +`docker compose up -d --build` direkt auf dem Host). + +### Einstellungen + +Alle Umgebungsvariablen aus [`.env.example`](../.env.example) funktionieren auch hier. Die wichtigsten: + +| Variable | Bedeutung | +|---|---| +| `PUBLIC_URL` | Adresse der Webseite für Einladungslinks | +| `LOG_RECEIVER_URL` | Adresse, an die die CS2-Container ihre Logs schicken (Live-Ergebnisse) | +| `CS2_PUBLIC_HOST` / `CS2_RCON_HOST` | IP des Docker-Hosts – Spieler verbinden sich dorthin, CS2 Mapveto steuert die Server darüber | +| `CS2_GSLT` | leer = LAN-Modus; mit Token öffentlich erreichbar (ein Token pro gleichzeitigem Match) | +| `CS2_PORT_RANGE_START/END` | Ports für Match-Container, zwei pro Match, in der Firewall freigeben | +| `CREATE_MATCH_PASSWORD` | Passwort zum Anlegen von Matches | +| `STREAM_KEY` | Schlüssel für `/stream?key=…` | + +**Passwörter und Schlüssel nur aus Buchstaben und Ziffern** wählen. + +## 3. Updaten + +1. Neues Image bauen (Schritt 1, gleicher Befehl). +2. Portainer → Stack `cs2-mapveto` → **Update the stack** (bzw. Container → *Recreate*). + +Die Datenbank liegt im Volume `cs2-mapveto-data` und bleibt erhalten. Laufende Match-Container übernimmt +CS2 Mapveto nach dem Neustart wieder; ein Serverstart, der gerade lief, wird als unterbrochen markiert und +lässt sich mit „Server neu starten“ wiederholen. + +## 4. Sicherheit + +Der Container hat über `/var/run/docker.sock` volle Kontrolle über Docker auf dem Host – das ist nötig, um +Match-Container zu starten, entspricht aber Root-Rechten auf dem Host. Deshalb: + +- Port 3000 nur im LAN anbieten, nicht ins Internet weiterleiten. +- `CREATE_MATCH_PASSWORD` setzen, wenn nicht jeder im LAN Matches anlegen soll. +- RCON-Ports (TCP 27100–27139) möglichst nur vom Host bzw. LAN erreichbar lassen. + +## 5. Fehlersuche + +| Symptom | Ursache / Lösung | +|---|---| +| „Server wird angefordert“ bleibt stehen | Container-Log prüfen (Portainer → `cs2-mapveto` → Logs); Docker-Socket eingebunden? | +| `permission denied … docker.sock` im Log | Socket nicht eingebunden oder Docker mit Rootless-Modus – Pfad zum Socket anpassen | +| Match-Container startet, „Server startet“ endet in Fehler | Ports 27100+ von `CS2_RCON_HOST` aus erreichbar? Firewall des Hosts | +| Keine Live-Ergebnisse | `LOG_RECEIVER_URL` muss die Host-IP mit Port 3000 sein, nicht `localhost` | +| Port bereits belegt | Eine zweite CS2-Mapveto-Instanz nutzt denselben Host (siehe Hinweis oben) | diff --git a/server/package.json b/server/package.json index bef4e60..4c8e468 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node --watch src/index.js", "start": "node src/index.js", - "test": "node --test test/" + "test": "node --test" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/server/src/app.js b/server/src/app.js index 22b57c3..26fe070 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -52,6 +52,9 @@ export function createApp(config, { adapter: adapterOverride, logger = console } }, async close() { manager.stopLoops(); + // Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt + await manager.idle(5000); + io.disposeRealtime(); io.close(); await new Promise((resolve) => server.close(() => resolve())); db.close(); diff --git a/server/src/realtime.js b/server/src/realtime.js index 739c016..0ffa103 100644 --- a/server/src/realtime.js +++ b/server/src/realtime.js @@ -111,5 +111,14 @@ export function attachRealtime(httpServer, { service, config }) { }); }); + // Beim Herunterfahren: ausstehende Sammel-Updates verwerfen, damit nichts mehr auf die Datenbank zugreift + io.disposeRealtime = () => { + clearTimeout(overviewTimer); + clearTimeout(streamTimer); + for (const t of pending.values()) clearTimeout(t); + pending.clear(); + service.removeAllListeners('changed'); + }; + return io; } diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 11ae7c8..c5fe199 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -86,6 +86,12 @@ export class ServerManager { this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000)); } + /** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */ + async idle(timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (this.busy.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50)); + } + stopLoops() { this.simulator.stopAll(); for (const t of this.autoAdvance.values()) clearTimeout(t);