Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3fd2f1351 | ||
|
|
af58156209 | ||
|
|
b5fa27fbd3 | ||
|
|
89f579a014 | ||
|
|
3cc682f8d5 | ||
|
|
685509cf4c | ||
|
|
ab3c94bffd | ||
|
|
69d620bb19 | ||
|
|
c1c9e7a9f9 | ||
|
|
657934b8c4 | ||
|
|
fcee62c146 | ||
|
|
0f3a66fed5 | ||
|
|
dfe6a7d218 | ||
|
|
39fe8da5f5 | ||
|
|
50dcb2537d | ||
|
|
69fb27f44b | ||
|
|
a6c7ba1a23 | ||
|
|
436f605356 | ||
|
|
f40ca1c55f | ||
|
|
1a0ab7ef00 | ||
|
|
c4b7b4c1ec | ||
|
|
54bb4aca12 | ||
|
|
ca7cbffeac | ||
|
|
e15677a3f5 | ||
|
|
7211284d7e | ||
|
|
a728f47519 | ||
|
|
9f34777d10 | ||
|
|
c4debd3014 | ||
|
|
5886461a9e | ||
|
|
09e73a9527 | ||
|
|
3e4a85f76c | ||
|
|
790109405a | ||
|
|
f24b2bedbc | ||
|
|
656bfbaadf | ||
|
|
fcc9371a9e | ||
|
|
be474ee143 | ||
|
|
a7539091ff | ||
|
|
301da179ed | ||
|
|
7bceceb31d | ||
|
|
eee4ceb315 | ||
|
|
c33185db3e | ||
|
|
1da08b256e | ||
|
|
1d627c212a | ||
|
|
e7f595176a | ||
|
|
bfe315d70c | ||
|
|
d2a811f177 | ||
|
|
c69d5548e5 | ||
|
|
af4f036515 | ||
|
|
91213d354f | ||
|
|
4fa4db3640 | ||
|
|
a98a34d769 | ||
|
|
3bfe087b7d | ||
|
|
43ecb8a1ad | ||
|
|
46d1954a6d | ||
|
|
30ec5359b1 | ||
|
|
5527c2ce58 | ||
|
|
1b7c13b592 | ||
|
|
e3a1ed4a63 | ||
|
|
84dad0993c | ||
|
|
3a109883b3 | ||
|
|
85041b2bff | ||
|
|
ea2207899f | ||
|
|
263c7e7b7c | ||
|
|
306b3aa7e5 | ||
|
|
34e5fc7d0e | ||
|
|
8be8b3d879 | ||
|
|
3a74e16bb8 | ||
|
|
f1fc1642ab | ||
|
|
db4a6bedf7 | ||
|
|
7be0d85ab5 | ||
|
|
e304556346 | ||
|
|
bf438511d6 | ||
|
|
f7520a6ca7 | ||
|
|
f08297dd0d |
No files matched your search
@@ -52,7 +52,16 @@ CS2_PORT_RANGE_END=27115
|
|||||||
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
|
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
|
||||||
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
||||||
CS2_DOCKER_EXTRA_ARGS=
|
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 <ssh-ziel>
|
||||||
|
# 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
|
CS2_START_TIMEOUT_MINUTES=20
|
||||||
|
# Spielports für den LAN-Reiter des Steam-Serverbrowsers (Steam sucht nur 27015–27020). Leer = aus.
|
||||||
|
CS2_LAN_PORTS=27015-27020
|
||||||
|
# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115)
|
||||||
|
CS2_TV_PORT_OFFSET=100
|
||||||
|
# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||||
|
CS2_SERVERNAME_PREFIX=MaxLAN
|
||||||
DOCKER_BIN=docker
|
DOCKER_BIN=docker
|
||||||
|
|
||||||
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
||||||
|
|||||||
@@ -236,6 +236,34 @@ Voraussetzungen:
|
|||||||
|
|
||||||
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
Container heißen `cs2mv-<matchId>` 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.
|
||||||
|
|
||||||
|
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||||
|
|
||||||
|
Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.
|
||||||
|
|
||||||
|
- Pro Host lassen sich die LAN-Ports in `CS2_DOCKER_HOSTS` mit `"lanPorts":"27016-27019"` einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. `"lanPorts":""` schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“.
|
||||||
|
- Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports.
|
||||||
|
- Server auf LAN-Ports laufen im Netz des Hosts (`--network host`), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben.
|
||||||
|
- Der Servername im Browser ist `MaxLAN | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`.
|
||||||
|
- Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne `CS2_GSLT` laufen sie mit `sv_lan 1` und erscheinen nicht im Internet-Reiter.
|
||||||
|
|
||||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||||
|
|
||||||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||||||
@@ -311,7 +339,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
|
|||||||
- Stream-Seite
|
- Stream-Seite
|
||||||
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
||||||
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
||||||
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), siehe `server/test/fixtures/echte-logs-wingman.log`.
|
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
||||||
|
|
||||||
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
||||||
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||||
@@ -357,6 +385,45 @@ Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`)
|
|||||||
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
||||||
|
|
||||||
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
||||||
|
### Hinter einem Reverse-Proxy
|
||||||
|
|
||||||
|
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
|
||||||
|
|
||||||
|
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
|
||||||
|
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
|
||||||
|
- **`.env` anpassen:**
|
||||||
|
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
|
||||||
|
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
|
||||||
|
- **Nicht über den Proxy:**
|
||||||
|
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
|
||||||
|
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
|
||||||
|
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
|
||||||
|
|
||||||
|
Beispiel für nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name veto.example.de;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.178.31:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
@@ -383,6 +450,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||||||
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
||||||
|
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
||||||
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||||||
|
|
||||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
const { current } = veto;
|
const { current } = veto;
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
useEffect(() => setSelected(null), [current?.index]);
|
useEffect(() => setSelected(null), [current?.index]);
|
||||||
|
const left = useCountdown(veto.remainingMs);
|
||||||
|
|
||||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||||
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
||||||
@@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
? 'Live-Ansicht – nur Captains können wählen.'
|
? 'Live-Ansicht – nur Captains können wählen.'
|
||||||
: 'Das andere Team ist am Zug.'}
|
: 'Das andere Team ist am Zug.'}
|
||||||
</span>
|
</span>
|
||||||
|
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||||
@@ -129,3 +131,28 @@ function statusText(m, teams) {
|
|||||||
default: return 'Verfügbar';
|
default: return 'Verfügbar';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
||||||
|
function TimerBar({ left, totalMs }) {
|
||||||
|
const seconds = Math.ceil(left / 1000);
|
||||||
|
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
||||||
|
return (
|
||||||
|
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
||||||
|
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
||||||
|
<span className="turn-seconds">{seconds} s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
||||||
|
function useCountdown(ms) {
|
||||||
|
const [left, setLeft] = useState(ms ?? null);
|
||||||
|
useEffect(() => {
|
||||||
|
setLeft(ms ?? null);
|
||||||
|
if (ms == null) return undefined;
|
||||||
|
const started = Date.now();
|
||||||
|
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [ms]);
|
||||||
|
return left;
|
||||||
|
}
|
||||||
@@ -26,9 +26,11 @@ const storeToken = (t) => {
|
|||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [token, setToken] = useState(getToken);
|
const [token, setToken] = useState(getToken);
|
||||||
const [matches, setMatches] = useState(null);
|
const [matches, setMatches] = useState(null);
|
||||||
|
const [hosts, setHosts] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [notice, setNotice] = useState(null);
|
const [notice, setNotice] = useState(null);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [tab, setTab] = useState('matches');
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||||
@@ -41,6 +43,7 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api('/admin/matches', { token });
|
const res = await api('/admin/matches', { token });
|
||||||
setMatches(res.matches);
|
setMatches(res.matches);
|
||||||
|
setHosts(res.hosts ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
@@ -92,17 +95,25 @@ export default function AdminPage() {
|
|||||||
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{token && <NotificationSettings token={token} setNotice={setNotice} />}
|
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||||
|
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
|
||||||
|
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||||||
|
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||||
|
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||||
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{matches === null && !error && <p className="muted">Lade Matches …</p>}
|
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||||
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
|
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||||
|
|
||||||
<div className="admin-list">
|
{tab === 'matches' && <div className="admin-list">
|
||||||
{matches?.map((m) => (
|
{matches?.map((m) => (
|
||||||
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,6 +276,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
||||||
|
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||||
|
const configured = m.server?.status === 'configured';
|
||||||
|
const paused = !!m.server?.paused;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel admin-card status-${m.status}`}>
|
<section className={`panel admin-card status-${m.status}`}>
|
||||||
@@ -277,6 +291,29 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
||||||
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
||||||
</div>
|
</div>
|
||||||
|
{m.status === 'live' && current && (
|
||||||
|
<div className="admin-live">
|
||||||
|
<span>Map {current.number}: {current.name}</span>
|
||||||
|
<strong>{current.score.A} : {current.score.B}</strong>
|
||||||
|
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m.server && (m.server.requestedAt || (m.server.address && m.server.status !== 'stopped')) && (
|
||||||
|
<div className="admin-address">
|
||||||
|
{m.server.address && m.server.status !== 'stopped' && (
|
||||||
|
<>
|
||||||
|
<span><span className="muted">Server</span> <code>{m.server.address.host}:{m.server.address.port}</code></span>
|
||||||
|
{m.server.address.tvPort && (
|
||||||
|
<span><span className="muted">CSTV</span> <code>{m.server.address.host}:{m.server.address.tvPort}</code></span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{m.server.requestedAt && (
|
||||||
|
<span><span className="muted">Angefordert</span> {formatDateTime(m.server.requestedAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||||
</div>
|
</div>
|
||||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,8 +322,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||||
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||||
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||||
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||||
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
||||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||||
@@ -369,3 +406,296 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||||||
|
function HostUsage({ hosts }) {
|
||||||
|
if (hosts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="host-usage">
|
||||||
|
<h2>Docker-Hosts</h2>
|
||||||
|
<div className="host-grid">
|
||||||
|
{hosts.map((h) => {
|
||||||
|
const percent = h.total ? Math.round((h.used / h.total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={h.name} className={`host-card ${percent >= 90 ? 'is-full' : ''}`}>
|
||||||
|
<div className="host-head">
|
||||||
|
<strong>{h.name}</strong>
|
||||||
|
<span className="muted small">{h.publicHost}</span>
|
||||||
|
<span className="host-count">{h.used} / {h.total} belegt</span>
|
||||||
|
</div>
|
||||||
|
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
{h.disk && (
|
||||||
|
<div className={`host-disk ${h.disk.freeBytes < 20e9 ? 'is-low' : ''}`}>
|
||||||
|
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{h.matches.length === 0 ? (
|
||||||
|
<p className="muted small">Keine Gameserver</p>
|
||||||
|
) : (
|
||||||
|
<ul className="host-matches">
|
||||||
|
{h.matches.map((m) => <li key={m.id}>{m.title} <span className="muted small">{m.status}</span></li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function gb(bytes) {
|
||||||
|
return `${Math.round(bytes / 1e9)} GB`;
|
||||||
|
}
|
||||||
|
const SETTING_GROUPS = [
|
||||||
|
{ key: 'match', title: 'Match-Regeln' },
|
||||||
|
{ key: 'tv', title: 'CSTV' },
|
||||||
|
{ key: 'times', title: 'Zeiten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toInput = (field, value) => (field.type === 'commands' ? value.join('; ') : value);
|
||||||
|
const shownDefault = (field) => {
|
||||||
|
if (field.type === 'bool') return field.default ? 'an' : 'aus';
|
||||||
|
if (field.type === 'commands') return field.default.length ? field.default.join('; ') : 'keine';
|
||||||
|
return field.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */
|
||||||
|
function ServerSettingsPanel({ token, setNotice }) {
|
||||||
|
const [fields, setFields] = useState(null);
|
||||||
|
const [values, setValues] = useState({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const apply = (list) => {
|
||||||
|
setFields(list);
|
||||||
|
setValues(Object.fromEntries(list.map((f) => [f.key, toInput(f, f.value)])));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api('/admin/server-settings', { token })
|
||||||
|
.then((res) => apply(res.fields))
|
||||||
|
.catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||||
|
}, [token, setNotice]);
|
||||||
|
|
||||||
|
if (!fields) return null;
|
||||||
|
const changedKeys = fields.filter((f) => String(values[f.key]) !== String(toInput(f, f.value))).map((f) => f.key);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const body = Object.fromEntries(changedKeys.map((key) => {
|
||||||
|
const field = fields.find((f) => f.key === key);
|
||||||
|
return [key, field.type === 'int' ? Number(values[key]) : values[key]];
|
||||||
|
}));
|
||||||
|
const res = await api('/admin/server-settings', { method: 'PUT', body: { values: body }, token });
|
||||||
|
apply(res.fields);
|
||||||
|
const parts = ['Einstellungen gespeichert'];
|
||||||
|
if (res.live.updated) parts.push(`${res.live.updated} laufende${res.live.updated === 1 ? 'r' : ''} Server aktualisiert`);
|
||||||
|
if (res.live.failed.length) parts.push(`${res.live.failed.length} Server nicht erreichbar`);
|
||||||
|
setNotice({ tone: res.live.failed.length ? 'error' : 'ok', text: parts.join(' · ') });
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = (f) => {
|
||||||
|
if (f.type === 'bool') {
|
||||||
|
return (
|
||||||
|
<label className="admin-check">
|
||||||
|
<input type="checkbox" checked={!!values[f.key]} onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })} />
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="setting-field">
|
||||||
|
<span>{f.label}</span>
|
||||||
|
{f.type === 'commands' ? (
|
||||||
|
<textarea rows={2} value={values[f.key]} placeholder="z. B. mp_freezetime 15; sv_alltalk 0"
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
|
||||||
|
) : (
|
||||||
|
<input type="number" min={f.min} max={f.max} value={values[f.key]}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-settings">
|
||||||
|
<h2>Gameserver</h2>
|
||||||
|
<p className="small muted">
|
||||||
|
Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
|
||||||
|
</p>
|
||||||
|
{SETTING_GROUPS.map((g) => (
|
||||||
|
<div key={g.key} className="setting-group">
|
||||||
|
<h3>{g.title}</h3>
|
||||||
|
<div className="setting-grid">
|
||||||
|
{fields.filter((f) => f.group === g.key).map((f) => (
|
||||||
|
<div key={f.key} className="setting-item">
|
||||||
|
{input(f)}
|
||||||
|
<div className="small muted">
|
||||||
|
{f.live && <span className="setting-live">live</span>}
|
||||||
|
{f.overridden ? `Vorgabe aus .env: ${shownDefault(f)}` : 'Vorgabe aus .env'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || changedKeys.length === 0}>Speichern</button>
|
||||||
|
{changedKeys.length > 0 && (
|
||||||
|
<button className="btn btn-sm" disabled={busy} onClick={() => apply(fields)}>Verwerfen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, lanPorts: '27015-27020', volume: 'cs2-mapveto-gamefiles' };
|
||||||
|
const HOST_COLUMNS = [
|
||||||
|
{ key: 'name', label: 'Name', placeholder: 'host1' },
|
||||||
|
{ key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' },
|
||||||
|
{ key: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' },
|
||||||
|
{ key: 'portStart', label: 'Port von', type: 'number' },
|
||||||
|
{ key: 'portEnd', label: 'Port bis', type: 'number' },
|
||||||
|
{ key: 'lanPorts', label: 'LAN-Ports (Steam)', placeholder: '27015-27020' },
|
||||||
|
{ key: 'volume', label: 'Volume', placeholder: 'cs2-mapveto-gamefiles' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Docker-Hosts verwalten und das CS2-Image auf allen Hosts gleichzeitig aktualisieren. */
|
||||||
|
function DockerHostsPanel({ token, setNotice }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const apply = useCallback((res) => {
|
||||||
|
setData(res);
|
||||||
|
setRows(res.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(() => api('/admin/docker-hosts', { token }), [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||||
|
}, [load, apply, setNotice]);
|
||||||
|
|
||||||
|
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
|
||||||
|
const pulling = !!data?.pull?.running;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pulling) return undefined;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [pulling, load]);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
if (!data.editable) return null;
|
||||||
|
|
||||||
|
const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
|
||||||
|
const dirty = JSON.stringify(rows) !== original;
|
||||||
|
const setCell = (i, key, value) => setRows(rows.map((r, j) => (j === i ? { ...r, [key]: value } : r)));
|
||||||
|
|
||||||
|
const run = async (fn, success) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fn();
|
||||||
|
apply(res);
|
||||||
|
if (success) setNotice({ tone: 'ok', text: success });
|
||||||
|
} catch (err) {
|
||||||
|
setNotice({ tone: 'error', text: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => run(() => api('/admin/docker-hosts', {
|
||||||
|
method: 'PUT',
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
hosts: rows.map((r) => ({
|
||||||
|
name: r.name.trim(),
|
||||||
|
dockerHost: r.dockerHost.trim() || null,
|
||||||
|
publicHost: r.publicHost.trim(),
|
||||||
|
...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}),
|
||||||
|
portStart: Number(r.portStart),
|
||||||
|
portEnd: Number(r.portEnd),
|
||||||
|
lanPorts: String(r.lanPorts ?? '').trim(),
|
||||||
|
volume: r.volume.trim() || undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}), 'Docker-Hosts gespeichert');
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
if (!window.confirm('Hosts aus der .env verwenden? Die hier gespeicherte Liste wird gelöscht.')) return;
|
||||||
|
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
|
||||||
|
};
|
||||||
|
|
||||||
|
const pull = () => {
|
||||||
|
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
|
||||||
|
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-settings">
|
||||||
|
<h2>Docker-Hosts</h2>
|
||||||
|
<p className="small muted">
|
||||||
|
{data.overridden ? 'Diese Liste ist im Admin-Bereich gespeichert und ersetzt CS2_DOCKER_HOSTS aus der .env.' : 'Aktuell gilt die Liste aus der .env.'}
|
||||||
|
{' '}Hosts mit laufenden Servern lassen sich erst nach dem Stoppen entfernen.
|
||||||
|
</p>
|
||||||
|
<div className="host-edit">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="host-edit-row">
|
||||||
|
{HOST_COLUMNS.map((c) => (
|
||||||
|
<label key={c.key} className={`setting-field host-col-${c.key}`}>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
<input type={c.type ?? 'text'} value={r[c.key] ?? ''} placeholder={c.placeholder}
|
||||||
|
onChange={(e) => setCell(i, c.key, e.target.value)} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button className="link-btn danger" disabled={busy || rows.length === 1}
|
||||||
|
onClick={() => setRows(rows.filter((_, j) => j !== i))}>Entfernen</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm" disabled={busy} onClick={() => setRows([...rows, { ...EMPTY_HOST }])}>Host hinzufügen</button>
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={busy || !dirty} onClick={save}>Hosts speichern</button>
|
||||||
|
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => apply(data)}>Verwerfen</button>}
|
||||||
|
{data.overridden && <button className="btn btn-sm btn-ghost" disabled={busy} onClick={reset}>Liste aus .env verwenden</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Image aktualisieren</h3>
|
||||||
|
<p className="small muted">
|
||||||
|
Lädt <code>{data.image}</code> auf allen Hosts gleichzeitig neu. Laufende Server laufen weiter, neue Server starten mit dem neuen Image.
|
||||||
|
</p>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={busy || pulling || dirty} onClick={pull}>
|
||||||
|
{pulling ? 'Wird aktualisiert …' : 'Auf allen Hosts aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{data.pull?.results?.length > 0 && (
|
||||||
|
<ul className="pull-results">
|
||||||
|
{data.pull.results.map((r) => (
|
||||||
|
<li key={r.name} className={`pull-${r.state}`}>
|
||||||
|
<strong>{r.name}</strong>
|
||||||
|
<span>{r.state === 'running' ? 'lädt …' : r.state === 'ok' ? 'aktuell' : 'fehlgeschlagen'}</span>
|
||||||
|
{r.message && <code className="small">{r.message}</code>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{data.pull.finishedAt && (
|
||||||
|
<li className="small muted">Zuletzt fertig: {new Date(data.pull.finishedAt).toLocaleString('de-DE')}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
|
||||||
|
function formatDateTime(ms) {
|
||||||
|
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@ export default function MatchPage() {
|
|||||||
|
|
||||||
{viewer.role === 'public' && <PublicNotice />}
|
{viewer.role === 'public' && <PublicNotice />}
|
||||||
|
|
||||||
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
|
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
|
||||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
||||||
<section className="stage">
|
<section className="stage">
|
||||||
<Stage {...ctx} />
|
<Stage {...ctx} />
|
||||||
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
|
|||||||
|
|
||||||
function Stage(ctx) {
|
function Stage(ctx) {
|
||||||
const { state, isOrg, act, busy } = ctx;
|
const { state, isOrg, act, busy } = ctx;
|
||||||
if (state.viewer.role === 'public') {
|
if (state.viewer.role === 'public' && state.status === 'lobby') {
|
||||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
|
||||||
}
|
}
|
||||||
if (state.status === 'lobby') {
|
if (state.status === 'lobby') {
|
||||||
const { A, B } = state.teams;
|
const { A, B } = state.teams;
|
||||||
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
|
|||||||
function PublicNotice() {
|
function PublicNotice() {
|
||||||
return (
|
return (
|
||||||
<div className="alert">
|
<div className="alert">
|
||||||
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
||||||
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js';
|
|||||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||||
import { getAccess } from '../lib/session.js';
|
import { getAccess } from '../lib/session.js';
|
||||||
import { useOverview } from '../lib/useMatch.js';
|
import { useOverview } from '../lib/useMatch.js';
|
||||||
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||||
@@ -32,7 +33,11 @@ export default function Overview() {
|
|||||||
return (
|
return (
|
||||||
<section key={g.key} className={`group group-${g.key}`}>
|
<section key={g.key} className={`group group-${g.key}`}>
|
||||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
|
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
||||||
|
<div className="run-grid">
|
||||||
|
{list.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ul className="match-list">
|
<ul className="match-list">
|
||||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -79,6 +84,34 @@ function MatchRow({ match: m }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
|
||||||
|
function RunningCard({ match: m }) {
|
||||||
|
const server = serverLabel(m.server);
|
||||||
|
const map = m.currentMap;
|
||||||
|
const live = map && !map.finished;
|
||||||
|
const bo1 = m.format === 'bo1';
|
||||||
|
return (
|
||||||
|
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
|
||||||
|
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
|
||||||
|
<div className="run-top">
|
||||||
|
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||||
|
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="run-teams">
|
||||||
|
<span className="run-team a">{m.teamA}</span>
|
||||||
|
<span className="run-score">
|
||||||
|
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
|
||||||
|
</span>
|
||||||
|
<span className="run-team b">{m.teamB}</span>
|
||||||
|
</div>
|
||||||
|
<div className="run-foot">
|
||||||
|
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
|
||||||
|
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CodeEntry() {
|
function CodeEntry() {
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|||||||
@@ -517,3 +517,75 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
.admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
||||||
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
.admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; }
|
||||||
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
.admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
|
||||||
|
.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
|
||||||
|
.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; }
|
||||||
|
.admin-tab:hover { color: var(--text); }
|
||||||
|
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
|
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||||
|
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||||
|
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||||
|
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
||||||
|
.admin-live strong { font-variant-numeric: tabular-nums; }
|
||||||
|
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
||||||
|
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
||||||
|
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
||||||
|
/* Laufende Matches in der Übersicht */
|
||||||
|
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||||
|
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
|
||||||
|
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
|
||||||
|
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
|
||||||
|
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
|
||||||
|
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
|
||||||
|
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
|
||||||
|
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes run-pulse { 50% { opacity: .55; } }
|
||||||
|
.run-status { font-weight: 600; }
|
||||||
|
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
|
||||||
|
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.run-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.run-team.b { color: var(--team-b); text-align: left; }
|
||||||
|
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
|
||||||
|
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
||||||
|
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
||||||
|
.run-series { font-variant-numeric: tabular-nums; }
|
||||||
|
.host-usage { margin-bottom: 20px; }
|
||||||
|
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
|
||||||
|
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.host-card.is-full { border-color: var(--pending); }
|
||||||
|
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
|
||||||
|
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
|
||||||
|
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
|
||||||
|
.host-card.is-full .host-bar span { background: var(--pending); }
|
||||||
|
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
|
||||||
|
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
|
||||||
|
.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; }
|
||||||
|
.host-disk.is-low { color: var(--error); font-weight: 600; }
|
||||||
|
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
|
||||||
|
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
|
||||||
|
.admin-settings h3 { margin: 16px 0 8px; font-size: .95rem; }
|
||||||
|
.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px 20px; }
|
||||||
|
.setting-item { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.setting-field { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.setting-field input, .setting-field textarea { width: 100%; box-sizing: border-box; font: inherit; color: var(--text); background: var(--bg, transparent); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
|
||||||
|
.setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||||
|
.setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); }
|
||||||
|
.host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.1fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.host-edit-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
.host-col-dockerHost { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
.pull-results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||||
|
.pull-results li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
|
||||||
|
.pull-results code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.pull-running span { color: var(--pending); }
|
||||||
|
.pull-ok span { color: var(--ok, #22c55e); }
|
||||||
|
.pull-failed span { color: var(--error); }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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 <ssh-ziel> [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 <ssh-ziel> [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 <<EOF
|
||||||
|
|
||||||
|
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
|
||||||
|
|
||||||
|
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
- name muss pro Host eindeutig sein.
|
||||||
|
- Frei sein müssen: 27015–27020 (LAN-Reiter von Steam), 27100–27139 und jeweils 100 darüber für CSTV
|
||||||
|
(27115–27120, 27200–27239). Pro Host sind bis zu 40 Matches möglich, 6 davon im LAN-Reiter.
|
||||||
|
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
|
||||||
|
EOF
|
||||||
Binary file not shown.
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { formatPortList } from './config.js';
|
||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ function bearer(req) {
|
|||||||
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
|
||||||
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
|
||||||
*/
|
*/
|
||||||
export function createAdminRouter({ service, manager, config }) {
|
export function createAdminRouter({ service, manager, config, settings }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||||||
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||||
@@ -77,20 +78,68 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
router.get('/session', requireAdmin, (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
// Discord-Meldungen: welche Ereignisse gemeldet werden
|
// Discord-Meldungen: welche Ereignisse gemeldet werden
|
||||||
|
// Gameserver-Einstellungen: Vorgaben aus der .env, vom Admin überschreibbar
|
||||||
|
router.get('/server-settings', requireAdmin, wrap(() => ({ fields: settings.view() })));
|
||||||
|
router.put('/server-settings', requireAdmin, wrap(async (req) => {
|
||||||
|
const changed = settings.update(req.body?.values ?? {});
|
||||||
|
if (changed.includes('vetoStepSeconds')) service.startVetoTimer();
|
||||||
|
const live = changed.length ? await manager.applyLiveSettings(changed) : { updated: 0, failed: [] };
|
||||||
|
return { fields: settings.view(), changed, live };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Docker-Hosts: Liste wie CS2_DOCKER_HOSTS, gespeichert in der Datenbank
|
||||||
|
const hostView = () => ({
|
||||||
|
hosts: (manager.adapter.hosts ?? []).map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
dockerHost: h.dockerHost,
|
||||||
|
publicHost: h.publicHost,
|
||||||
|
rconHost: h.rconHost,
|
||||||
|
portStart: h.portRangeStart,
|
||||||
|
portEnd: h.portRangeEnd,
|
||||||
|
volume: h.volume,
|
||||||
|
lanPorts: formatPortList(h.lanPorts),
|
||||||
|
})),
|
||||||
|
overridden: settings.hostsOverridden(),
|
||||||
|
editable: !!manager.adapter.setHosts,
|
||||||
|
image: config.docker.image,
|
||||||
|
pull: manager.adapter.pullStatus?.() ?? null,
|
||||||
|
});
|
||||||
|
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
|
||||||
|
router.put('/docker-hosts', requireAdmin, wrap((req) => {
|
||||||
|
const list = req.body?.hosts;
|
||||||
|
const hosts = settings.parseHosts(list);
|
||||||
|
manager.setDockerHosts(hosts);
|
||||||
|
settings.saveHosts(list, hosts);
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
router.delete('/docker-hosts', requireAdmin, wrap(() => {
|
||||||
|
manager.setDockerHosts(settings.defaultHosts);
|
||||||
|
settings.resetHosts();
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
||||||
|
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
|
||||||
|
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
|
||||||
|
manager.adapter.pullImage().catch(() => {});
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
|
||||||
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||||||
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||||
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||||
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured');
|
||||||
if (!(await service.notifier.send('Testmeldung aus CS2 Mapveto'))) {
|
const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] };
|
||||||
|
if (!(await service.notifier.send(test))) {
|
||||||
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed');
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
router.get('/matches', requireAdmin, wrap(() => {
|
router.get('/matches', requireAdmin, wrap(async () => {
|
||||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
|
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||||
|
|||||||
+10
-2
@@ -11,18 +11,25 @@ import { MatchService } from './matchService.js';
|
|||||||
import { attachRealtime } from './realtime.js';
|
import { attachRealtime } from './realtime.js';
|
||||||
import { createServerAdapter } from './servers/adapter.js';
|
import { createServerAdapter } from './servers/adapter.js';
|
||||||
import { ServerManager } from './servers/serverManager.js';
|
import { ServerManager } from './servers/serverManager.js';
|
||||||
|
import { ServerSettings } from './serverSettings.js';
|
||||||
|
|
||||||
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
|
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
|
||||||
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
|
||||||
const db = openDatabase(config.databaseFile);
|
const db = openDatabase(config.databaseFile);
|
||||||
|
// Vom Admin geänderte Gameserver-Einstellungen auf die Konfiguration legen, bevor der Adapter entsteht
|
||||||
|
const settings = new ServerSettings({ db, config });
|
||||||
|
settings.load();
|
||||||
const pools = loadMapPools(config.mapPoolFile);
|
const pools = loadMapPools(config.mapPoolFile);
|
||||||
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
||||||
const service = new MatchService({ db, pools, config, notifier });
|
const service = new MatchService({ db, pools, config, notifier });
|
||||||
|
|
||||||
let manager;
|
let manager;
|
||||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
const adapter = adapterOverride ?? createServerAdapter(config, {
|
||||||
|
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
||||||
|
});
|
||||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||||
manager.init();
|
manager.init();
|
||||||
|
service.startVetoTimer();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
if (config.trustProxy) app.set('trust proxy', 1);
|
if (config.trustProxy) app.set('trust proxy', 1);
|
||||||
@@ -32,7 +39,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
res.set('Referrer-Policy', 'no-referrer');
|
res.set('Referrer-Policy', 'no-referrer');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use('/api', createApiRouter({ service, manager, config, pools, adapter }));
|
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings }));
|
||||||
|
|
||||||
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
|
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
|
||||||
const dist = path.join(REPO_ROOT, 'client', 'dist');
|
const dist = path.join(REPO_ROOT, 'client', 'dist');
|
||||||
@@ -54,6 +61,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
manager.stopLoops();
|
manager.stopLoops();
|
||||||
|
service.stopVetoTimer();
|
||||||
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||||
await manager.idle(5000);
|
await manager.idle(5000);
|
||||||
io.disposeRealtime();
|
io.disposeRealtime();
|
||||||
|
|||||||
@@ -22,6 +22,61 @@ function bool(name, fallback) {
|
|||||||
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Portliste aus Text wie "27015-27020" oder "27016,27018-27019". Leer = keine Ports. */
|
||||||
|
export function parsePortList(value, label = 'LAN-Ports') {
|
||||||
|
if (Array.isArray(value)) value = value.join(',');
|
||||||
|
const ports = [];
|
||||||
|
for (const part of String(value ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
|
||||||
|
const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
|
||||||
|
if (!m) throw new Error(`${label}: "${part}" ist kein Port oder Bereich`);
|
||||||
|
const from = Number(m[1]);
|
||||||
|
const to = Number(m[2] ?? m[1]);
|
||||||
|
if (from < 1024 || to > 65535 || to < from || to - from > 100) throw new Error(`${label}: Bereich ${part} ist ungültig`);
|
||||||
|
for (let p = from; p <= to; p += 1) if (!ports.includes(p)) ports.push(p);
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portliste kompakt als Text, z. B. [27015, 27016, 27017] -> "27015-27017". */
|
||||||
|
export function formatPortList(ports = []) {
|
||||||
|
const sorted = [...ports].sort((a, b) => a - b);
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < sorted.length; i += 1) {
|
||||||
|
let j = i;
|
||||||
|
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j += 1;
|
||||||
|
parts.push(i === j ? String(sorted[i]) : `${sorted[i]}-${sorted[j]}`);
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return parts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
lanPorts: h.lanPorts === undefined ? undefined : parsePortList(h.lanPorts, `LAN-Ports von ${h.name}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function str(name, fallback = '') {
|
function str(name, fallback = '') {
|
||||||
const raw = process.env[name];
|
const raw = process.env[name];
|
||||||
return raw === undefined || raw === '' ? fallback : raw;
|
return raw === undefined || raw === '' ? fallback : raw;
|
||||||
@@ -52,6 +107,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||||
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
||||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||||
|
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||||
|
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
||||||
|
|
||||||
match: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
@@ -83,6 +140,16 @@ export function loadConfig(overrides = {}) {
|
|||||||
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
|
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
|
||||||
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
|
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
|
||||||
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
|
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
|
||||||
|
// Spielports, die der Steam-Serverbrowser im LAN-Reiter absucht (Steam fragt nur 27015–27020 ab).
|
||||||
|
// Diese Ports werden zuerst vergeben, CSTV kommt dann aus dem normalen Portbereich. Leer = aus.
|
||||||
|
lanPorts: parsePortList(str('CS2_LAN_PORTS', '27015-27020'), 'CS2_LAN_PORTS'),
|
||||||
|
// Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115
|
||||||
|
tvPortOffset: int('CS2_TV_PORT_OFFSET', 100),
|
||||||
|
// Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||||
|
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'MaxLAN'),
|
||||||
|
// 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: {
|
staticPool: {
|
||||||
|
|||||||
@@ -128,4 +128,6 @@ function migrate(db) {
|
|||||||
for (const [name, type] of add) {
|
for (const [name, type] of add) {
|
||||||
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||||
}
|
}
|
||||||
|
const serverColumns = columns('servers');
|
||||||
|
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
@@ -27,12 +27,14 @@ export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
/** Liefert true, wenn Discord die Nachricht angenommen hat. */
|
||||||
async send(content) {
|
async send(message) {
|
||||||
|
// Text oder vollständige Discord-Payload (z. B. mit embeds)
|
||||||
|
const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message;
|
||||||
try {
|
try {
|
||||||
const res = await fetchImpl(url, {
|
const res = await fetchImpl(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: content.slice(0, MAX_LENGTH) }),
|
body: JSON.stringify(payload),
|
||||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (res.ok) return true;
|
if (res.ok) return true;
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ function accessTokens(req) {
|
|||||||
return [bearer(req), req.get('x-player-token')?.trim() || null];
|
return [bearer(req), req.get('x-player-token')?.trim() || null];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiRouter({ service, manager, config, pools, adapter }) {
|
export function createApiRouter({ service, manager, config, pools, adapter, settings }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
|
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
|
||||||
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use(express.json({ limit: '32kb' }));
|
router.use(express.json({ limit: '32kb' }));
|
||||||
router.use('/admin', createAdminRouter({ service, manager, config }));
|
router.use('/admin', createAdminRouter({ service, manager, config, settings }));
|
||||||
|
|
||||||
const limiters = {
|
const limiters = {
|
||||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||||
|
|||||||
+109
-13
@@ -341,11 +341,68 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(firstTeam, this.now(), this.now(), match.id);
|
.run(firstTeam, this.now(), this.now(), match.id);
|
||||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||||
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
||||||
this.notify('veto_start', match.id, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`);
|
this.notify('veto_start', match.id, {
|
||||||
|
title: '🎮 Veto gestartet',
|
||||||
|
description: `**${this.matchTitle(match.id)}**`,
|
||||||
|
color: 0x3b82f6,
|
||||||
|
fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }],
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
this.changed(viewer.matchId);
|
this.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
|
||||||
|
vetoDeadline(match) {
|
||||||
|
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
|
||||||
|
const actions = this.vetoActions(match.id);
|
||||||
|
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
|
||||||
|
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
vetoRemainingMs(match) {
|
||||||
|
const deadline = this.vetoDeadline(match);
|
||||||
|
return deadline === null ? null : Math.max(0, deadline - this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
|
||||||
|
autoVetoStep(match) {
|
||||||
|
const state = this.vetoState(match);
|
||||||
|
if (state.done || !state.current) return;
|
||||||
|
const { type, team, index } = state.current;
|
||||||
|
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
||||||
|
const viewer = {
|
||||||
|
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
|
||||||
|
};
|
||||||
|
const action = type === 'side'
|
||||||
|
? { stepIndex: index, side: pick(['CT', 'T']) }
|
||||||
|
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
|
||||||
|
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
|
||||||
|
this.vetoAction(viewer, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
|
||||||
|
autoVetoTimeouts() {
|
||||||
|
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
|
||||||
|
const deadline = this.vetoDeadline(match);
|
||||||
|
if (deadline === null || this.now() < deadline) continue;
|
||||||
|
try {
|
||||||
|
this.autoVetoStep(match);
|
||||||
|
} catch (err) {
|
||||||
|
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startVetoTimer() {
|
||||||
|
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
|
||||||
|
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopVetoTimer() {
|
||||||
|
clearInterval(this.vetoTimer);
|
||||||
|
this.vetoTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
vetoAction(viewer, { stepIndex, map, side }) {
|
vetoAction(viewer, { stepIndex, map, side }) {
|
||||||
this.require(viewer, 'captain');
|
this.require(viewer, 'captain');
|
||||||
let finished = false;
|
let finished = false;
|
||||||
@@ -519,8 +576,16 @@ export class MatchService extends EventEmitter {
|
|||||||
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden';
|
||||||
this.log(matchId, 'score',
|
this.log(matchId, 'score',
|
||||||
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
|
`Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`);
|
||||||
this.notify('map_end', matchId, `${this.matchTitle(matchId)}
|
this.notify('map_end', matchId, {
|
||||||
Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`);
|
title: '🗺️ Map beendet',
|
||||||
|
description: `**${this.matchTitle(matchId)}**`,
|
||||||
|
color: 0xf59e0b,
|
||||||
|
fields: [
|
||||||
|
{ name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true },
|
||||||
|
{ name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true },
|
||||||
|
{ name: 'Sieger', value: winner, inline: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
if (ended) {
|
if (ended) {
|
||||||
@@ -529,8 +594,18 @@ Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${
|
|||||||
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||||
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
||||||
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
||||||
if (decided) this.notify('series_end', matchId, `${this.matchTitle(matchId)}
|
if (decided) {
|
||||||
Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
const loser = decided === 'A' ? 'B' : 'A';
|
||||||
|
this.notify('series_end', matchId, {
|
||||||
|
title: '🏆 Serie entschieden',
|
||||||
|
description: `**${this.matchTitle(matchId)}**`,
|
||||||
|
color: 0x22c55e,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Sieger', value: this.teamName(ended.match, decided), inline: true },
|
||||||
|
{ name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||||
this.changed(matchId);
|
this.changed(matchId);
|
||||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||||
@@ -562,8 +637,12 @@ Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decide
|
|||||||
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
|
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
|
||||||
}
|
}
|
||||||
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
|
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
|
||||||
this.notify('match_closed', match.id, `${this.matchTitle(match.id)}
|
const finished = status === 'finished';
|
||||||
${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`);
|
this.notify('match_closed', match.id, {
|
||||||
|
title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen',
|
||||||
|
description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`,
|
||||||
|
color: finished ? 0x6b7280 : 0x9ca3af,
|
||||||
|
});
|
||||||
this.setStatus(match.id, status);
|
this.setStatus(match.id, status);
|
||||||
this.emit('closed', match.id);
|
this.emit('closed', match.id);
|
||||||
}
|
}
|
||||||
@@ -610,11 +689,13 @@ ${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen
|
|||||||
return this.notificationSettings();
|
return this.notificationSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
notify(type, matchId, text) {
|
/** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */
|
||||||
|
notify(type, matchId, embed) {
|
||||||
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
|
if (!this.notifier.enabled || !this.notificationSettings().events[type]) return;
|
||||||
const link = this.config.streamKey ? '' : `
|
const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`;
|
||||||
${this.config.publicUrl}/stream/match/${matchId}`;
|
this.notifier.send({
|
||||||
this.notifier.send(`${text}${link}`);
|
embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log(matchId, type, message) {
|
log(matchId, type, message) {
|
||||||
@@ -710,7 +791,14 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
|
|||||||
return {
|
return {
|
||||||
seriesScore: maps.length ? seriesScore(maps) : null,
|
seriesScore: maps.length ? seriesScore(maps) : null,
|
||||||
currentMap: current && (current.status === 'live' || current.status === 'done')
|
currentMap: current && (current.status === 'live' || current.status === 'done')
|
||||||
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
|
? {
|
||||||
|
number: current.number,
|
||||||
|
name: this.mapName(m, current.map),
|
||||||
|
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
|
||||||
|
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
|
||||||
|
score: { A: current.score_a, B: current.score_b },
|
||||||
|
finished: current.status === 'done',
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
teamA: m.team_a_name,
|
teamA: m.team_a_name,
|
||||||
@@ -804,7 +892,7 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
|
|||||||
requireFullTeams: this.config.requireFullTeams,
|
requireFullTeams: this.config.requireFullTeams,
|
||||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||||
maps: pool.maps,
|
maps: pool.maps,
|
||||||
veto: publicView
|
veto: publicView && match.status === 'lobby'
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
started: match.status !== 'lobby',
|
started: match.status !== 'lobby',
|
||||||
@@ -812,6 +900,8 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
|
|||||||
steps: veto.steps,
|
steps: veto.steps,
|
||||||
maps: veto.maps,
|
maps: veto.maps,
|
||||||
current: match.status === 'veto' ? veto.current : null,
|
current: match.status === 'veto' ? veto.current : null,
|
||||||
|
remainingMs: this.vetoRemainingMs(match),
|
||||||
|
stepSeconds: this.config.vetoStepSeconds,
|
||||||
done: veto.done,
|
done: veto.done,
|
||||||
history: actions.map((a) => ({
|
history: actions.map((a) => ({
|
||||||
index: a.idx,
|
index: a.idx,
|
||||||
@@ -861,10 +951,16 @@ ${this.config.publicUrl}/stream/match/${matchId}`;
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
message: row.message,
|
message: row.message,
|
||||||
playerCount: row.player_count,
|
playerCount: row.player_count,
|
||||||
|
paused: !!row.paused,
|
||||||
attempts: row.attempts,
|
attempts: row.attempts,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||||
};
|
};
|
||||||
|
if (viewer.isOrganizer) {
|
||||||
|
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
|
||||||
|
view.requestedAt = row.started_at ?? null;
|
||||||
|
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
|
||||||
|
}
|
||||||
const configured = row.status === 'configured';
|
const configured = row.status === 'configured';
|
||||||
if (!configured || viewer.role === 'public') return view;
|
if (!configured || viewer.role === 'public') return view;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Gameserver-Einstellungen, die der Admin zur Laufzeit ändern kann. Die Werte aus der .env sind die
|
||||||
|
// Vorgabe; Änderungen landen in der Tabelle settings und überschreiben die Vorgabe auch nach einem Neustart.
|
||||||
|
|
||||||
|
import { HttpError } from './matchService.js';
|
||||||
|
import { parseDockerHosts } from './config.js';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'server_settings';
|
||||||
|
const HOSTS_KEY = 'docker_hosts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
|
||||||
|
* Server-Manager und Docker-Adapter lesen. `live` = wird an laufende Server geschickt.
|
||||||
|
*/
|
||||||
|
export const SERVER_SETTING_FIELDS = [
|
||||||
|
{ key: 'maxRoundsCompetitive', group: 'match', label: 'Runden Competitive (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsCompetitive, set: (c, v) => { c.match.maxRoundsCompetitive = v; } },
|
||||||
|
{ key: 'maxRoundsWingman', group: 'match', label: 'Runden Wingman (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
|
||||||
|
get: (c) => c.match.maxRoundsWingman, set: (c, v) => { c.match.maxRoundsWingman = v; } },
|
||||||
|
{ key: 'overtime', group: 'match', label: 'Verlängerung (mp_overtime_enable)', type: 'bool', live: true,
|
||||||
|
get: (c) => c.match.overtime, set: (c, v) => { c.match.overtime = v; } },
|
||||||
|
{ key: 'extraCommands', group: 'match', label: 'Zusätzliche Konsolenbefehle (durch ; getrennt)', type: 'commands', live: true,
|
||||||
|
get: (c) => c.match.extraCommands, set: (c, v) => { c.match.extraCommands = v; } },
|
||||||
|
{ key: 'tvEnabled', group: 'tv', label: 'CSTV aktiv (wirkt ab dem nächsten Mapwechsel)', type: 'bool', live: false,
|
||||||
|
get: (c) => c.match.tvEnabled, set: (c, v) => { c.match.tvEnabled = v; } },
|
||||||
|
{ key: 'tvDelaySeconds', group: 'tv', label: 'CSTV-Verzögerung in Sekunden (tv_delay)', type: 'int', min: 0, max: 300, live: true,
|
||||||
|
get: (c) => c.match.tvDelaySeconds, set: (c, v) => { c.match.tvDelaySeconds = v; } },
|
||||||
|
{ key: 'vetoStepSeconds', group: 'times', label: 'Zeit pro Veto-Schritt in Sekunden (0 = ohne Frist)', type: 'int', min: 0, max: 600, live: false,
|
||||||
|
get: (c) => c.vetoStepSeconds, set: (c, v) => { c.vetoStepSeconds = v; } },
|
||||||
|
{ key: 'idleTimeoutMinutes', group: 'times', label: 'Leerlauf bis zum Abschalten in Minuten', type: 'int', min: 1, max: 1440, live: false,
|
||||||
|
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
|
||||||
|
{ key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
|
||||||
|
get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
|
||||||
|
{ key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
|
||||||
|
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldFor = (key) => SERVER_SETTING_FIELDS.find((f) => f.key === key);
|
||||||
|
|
||||||
|
/** Konsolenbefehle aus Text: getrennt durch ; oder Zeilenumbruch. Befehle, die Zugänge ändern oder den Server beenden, sind gesperrt. */
|
||||||
|
export function parseCommands(value) {
|
||||||
|
const list = (Array.isArray(value) ? value : String(value ?? '').split(/[;\r\n]+/))
|
||||||
|
.map((c) => String(c).replace(/[\r\n;]/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length > 30) throw new HttpError(400, 'Höchstens 30 zusätzliche Befehle', 'invalid');
|
||||||
|
for (const c of list) {
|
||||||
|
if (c.length > 120) throw new HttpError(400, `Befehl zu lang: ${c.slice(0, 30)} …`, 'invalid');
|
||||||
|
if (/^(rcon_password|sv_password|tv_password|rcon|quit|exit|_restart|logaddress_\w*)\b/i.test(c)) {
|
||||||
|
throw new HttpError(400, `Befehl nicht erlaubt: ${c.split(' ')[0]}`, 'invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(field, raw) {
|
||||||
|
if (field.type === 'bool') {
|
||||||
|
if (typeof raw !== 'boolean') throw new HttpError(400, `${field.label}: an oder aus erwartet`, 'invalid');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (field.type === 'int') {
|
||||||
|
const value = Number(raw);
|
||||||
|
if (raw === '' || raw === null || !Number.isInteger(value) || value < field.min || value > field.max) {
|
||||||
|
throw new HttpError(400, `${field.label}: ganze Zahl von ${field.min} bis ${field.max} erwartet`, 'invalid');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parseCommands(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
export class ServerSettings {
|
||||||
|
constructor({ db, config }) {
|
||||||
|
this.db = db;
|
||||||
|
this.config = config;
|
||||||
|
// Vorgaben aus der .env merken, damit der Admin zurück auf die Vorgabe kann
|
||||||
|
this.defaults = Object.fromEntries(SERVER_SETTING_FIELDS.map((f) => [f.key, structuredClone(f.get(config))]));
|
||||||
|
this.defaultHosts = config.docker.hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
readJson(key) {
|
||||||
|
const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(key, value) {
|
||||||
|
this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.run(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Start: gespeicherte Werte auf das config-Objekt legen (vor dem Anlegen des Docker-Adapters). */
|
||||||
|
load() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
for (const field of SERVER_SETTING_FIELDS) {
|
||||||
|
if (!(field.key in saved)) continue;
|
||||||
|
try {
|
||||||
|
field.set(this.config, parseValue(field, saved[field.key]));
|
||||||
|
} catch {
|
||||||
|
// Ungültiger gespeicherter Wert: Vorgabe aus der .env behalten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hosts = this.readJson(HOSTS_KEY);
|
||||||
|
if (hosts) {
|
||||||
|
try {
|
||||||
|
this.config.docker.hosts = parseDockerHosts(JSON.stringify(hosts));
|
||||||
|
} catch {
|
||||||
|
// Kaputte Host-Liste ignorieren, die .env gilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
return SERVER_SETTING_FIELDS.map(({ key, group, label, type, min, max, live, get }) => ({
|
||||||
|
key, group, label, type, min, max, live,
|
||||||
|
value: get(this.config),
|
||||||
|
default: this.defaults[key],
|
||||||
|
overridden: key in saved,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt geänderte Werte. Liefert die Schlüssel, die sich wirklich geändert haben. */
|
||||||
|
update(input = {}) {
|
||||||
|
const parsed = Object.entries(input).map(([key, raw]) => {
|
||||||
|
const field = fieldFor(key);
|
||||||
|
if (!field) throw new HttpError(400, `Unbekannte Einstellung: ${key}`, 'invalid');
|
||||||
|
return [field, parseValue(field, raw)];
|
||||||
|
});
|
||||||
|
const saved = this.readJson(SETTINGS_KEY) ?? {};
|
||||||
|
const changed = [];
|
||||||
|
for (const [field, value] of parsed) {
|
||||||
|
if (!same(field.get(this.config), value)) changed.push(field.key);
|
||||||
|
field.set(this.config, value);
|
||||||
|
if (same(value, this.defaults[field.key])) delete saved[field.key];
|
||||||
|
else saved[field.key] = value;
|
||||||
|
}
|
||||||
|
this.writeJson(SETTINGS_KEY, saved);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft und speichert eine neue Host-Liste (Format wie CS2_DOCKER_HOSTS). */
|
||||||
|
parseHosts(list) {
|
||||||
|
if (!Array.isArray(list)) throw new HttpError(400, 'Host-Liste erwartet', 'invalid');
|
||||||
|
let hosts;
|
||||||
|
try {
|
||||||
|
hosts = parseDockerHosts(JSON.stringify(list));
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(400, err.message, 'invalid');
|
||||||
|
}
|
||||||
|
for (const h of hosts) {
|
||||||
|
if (h.dockerHost && !/^(ssh:\/\/[\w.@-]+(:\d+)?|tcp:\/\/[\w.-]+:\d+)$/.test(h.dockerHost)) {
|
||||||
|
throw new HttpError(400, `Ungültiges Docker-Ziel für ${h.name}: ${h.dockerHost}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (!/^[\w.-]+$/.test(h.name)) throw new HttpError(400, `Ungültiger Name: ${h.name}`, 'invalid');
|
||||||
|
if (!/^[\w.-]+$/.test(h.publicHost) || !/^[\w.-]+$/.test(h.rconHost)) {
|
||||||
|
throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid');
|
||||||
|
}
|
||||||
|
if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid');
|
||||||
|
const lan = h.lanPorts ?? this.config.docker.lanPorts ?? [];
|
||||||
|
const rangeStart = h.portRangeStart ?? this.config.docker.portRangeStart;
|
||||||
|
const rangeEnd = h.portRangeEnd ?? this.config.docker.portRangeEnd;
|
||||||
|
if (lan.some((p) => p >= rangeStart && p <= rangeEnd)) {
|
||||||
|
throw new HttpError(400, `LAN-Ports von ${h.name} dürfen nicht im normalen Portbereich liegen`, 'invalid');
|
||||||
|
}
|
||||||
|
const start = h.portRangeStart ?? this.config.docker.portRangeStart;
|
||||||
|
const end = h.portRangeEnd ?? this.config.docker.portRangeEnd;
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) {
|
||||||
|
throw new HttpError(400, `Ungültiger Portbereich für ${h.name}`, 'invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHosts(list, hosts) {
|
||||||
|
this.writeJson(HOSTS_KEY, list);
|
||||||
|
this.config.docker.hosts = hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt die gespeicherte Host-Liste; danach gilt wieder die .env. */
|
||||||
|
resetHosts() {
|
||||||
|
this.db.prepare('DELETE FROM settings WHERE key = ?').run(HOSTS_KEY);
|
||||||
|
this.config.docker.hosts = this.defaultHosts;
|
||||||
|
return this.defaultHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsOverridden() {
|
||||||
|
return this.readJson(HOSTS_KEY) != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DemoServerAdapter } from './demoAdapter.js';
|
import { DemoServerAdapter } from './demoAdapter.js';
|
||||||
import { DockerServerAdapter } from './dockerAdapter.js';
|
import { DockerHostPool } from './dockerAdapter.js';
|
||||||
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
|
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
|
||||||
|
|
||||||
export function createServerAdapter(config, deps) {
|
export function createServerAdapter(config, deps) {
|
||||||
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
|
|||||||
case 'demo':
|
case 'demo':
|
||||||
return new DemoServerAdapter(config.demo);
|
return new DemoServerAdapter(config.demo);
|
||||||
case 'docker':
|
case 'docker':
|
||||||
return new DockerServerAdapter(config.docker, deps);
|
return new DockerHostPool(config.docker, deps);
|
||||||
case 'static':
|
case 'static':
|
||||||
return new StaticPoolServerAdapter(config.staticPool, deps);
|
return new StaticPoolServerAdapter(config.staticPool, deps);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,8 +2,49 @@ import { execFile } from 'node:child_process';
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
|
import { sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant Spiel- und CSTV-Port für einen neuen Server. Der CSTV-Port liegt immer `tvPortOffset` (Standard 100)
|
||||||
|
* über dem Spielport. Spielports kommen zuerst aus den LAN-Ports (damit der Server im LAN-Reiter des
|
||||||
|
* Steam-Serverbrowsers auftaucht), danach aus dem normalen Bereich. null = kein Platz.
|
||||||
|
*/
|
||||||
|
export function planPorts(host, used) {
|
||||||
|
const offset = host.tvPortOffset ?? 100;
|
||||||
|
const free = (p) => !used.has(p) && p <= 65535;
|
||||||
|
const candidates = [...(host.lanPorts ?? [])];
|
||||||
|
for (let p = host.portRangeStart; p <= host.portRangeEnd; p += 1) candidates.push(p);
|
||||||
|
const port = candidates.find((p) => free(p) && free(p + offset));
|
||||||
|
return port === undefined ? null : { port, tvPort: port + offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Server passen noch auf einen Host, wenn bereits `used` belegt ist. */
|
||||||
|
export function countSlots(host, used) {
|
||||||
|
const taken = new Set(used);
|
||||||
|
let count = 0;
|
||||||
|
for (;;) {
|
||||||
|
const plan = planPorts(host, taken);
|
||||||
|
if (!plan) return count;
|
||||||
|
taken.add(plan.port);
|
||||||
|
taken.add(plan.tvPort);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
||||||
|
const PORT_BUSY = /port is already allocated|address already in use/i;
|
||||||
|
const DISK_CACHE_MS = 60_000;
|
||||||
|
|
||||||
|
/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */
|
||||||
|
export function parseDf(output) {
|
||||||
|
const parts = String(output).trim().split(/\s+/);
|
||||||
|
const totalBytes = Number(parts[1]);
|
||||||
|
const freeBytes = Number(parts[3]);
|
||||||
|
if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null;
|
||||||
|
return { totalBytes, freeBytes };
|
||||||
|
}
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
|
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
|
||||||
@@ -36,17 +77,25 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
if (this.runDocker) return this.runDocker(args);
|
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||||
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();
|
return stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
allocatePort() {
|
allocatePorts(blocked = new Set()) {
|
||||||
const used = this.getUsedPorts();
|
const used = new Set([...this.getUsedPorts(), ...blocked]);
|
||||||
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
|
const plan = planPorts(this.options, used);
|
||||||
if (!used.has(p) && !used.has(p + 1)) return p;
|
if (!plan) throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
|
||||||
}
|
return plan;
|
||||||
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
|
}
|
||||||
|
|
||||||
|
/** Servername im Steam-Serverbrowser, z. B. "MaxLAN | Team A vs. Team B". */
|
||||||
|
serverName(matchId, teamNames) {
|
||||||
|
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
|
||||||
|
if (!teamNames) return `${prefix} ${matchId}`.trim();
|
||||||
|
const title = `${sanitizeConsoleText(teamNames.A)} vs. ${sanitizeConsoleText(teamNames.B)}`;
|
||||||
|
return (prefix ? `${prefix} | ${title}` : title).slice(0, 63);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(args) {
|
async start(args) {
|
||||||
@@ -97,13 +146,34 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch({ matchId, mode, secrets }) {
|
/**
|
||||||
const port = this.allocatePort();
|
* Im Host-Netz meldet Docker keinen Portkonflikt, CS2 startet dann einfach nicht. Deshalb vorher per SSH
|
||||||
const tvPort = port + 1;
|
* nachsehen, ob ein fremder Dienst den Port belegt. Ohne SSH-Ziel (oder in Tests) gilt der Port als frei.
|
||||||
|
*/
|
||||||
|
async hostPortsFree(ports) {
|
||||||
|
if (this.runDocker || !this.options.dockerHost?.startsWith('ssh://')) return true;
|
||||||
|
try {
|
||||||
|
const filter = ports.map((p) => `sport = :${Number(p)}`).join(' or ');
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', this.options.dockerHost.slice('ssh://'.length),
|
||||||
|
`ss -Hlnut '( ${filter} )'`,
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
return stdout.trim() === '';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async launch({ matchId, mode, secrets, teamNames }, blocked = new Set()) {
|
||||||
|
const { port, tvPort } = this.allocatePorts(blocked);
|
||||||
|
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||||
|
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||||
|
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||||
|
}
|
||||||
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 = {
|
||||||
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
||||||
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
|
CS2_SERVERNAME: this.serverName(matchId, teamNames),
|
||||||
CS2_PORT: String(port),
|
CS2_PORT: String(port),
|
||||||
CS2_RCONPW: secrets.rconPassword,
|
CS2_RCONPW: secrets.rconPassword,
|
||||||
CS2_PW: secrets.serverPassword,
|
CS2_PW: secrets.serverPassword,
|
||||||
@@ -122,22 +192,42 @@ export class DockerServerAdapter {
|
|||||||
};
|
};
|
||||||
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
|
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
|
||||||
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
||||||
args.push(
|
if (lan) {
|
||||||
'-p', `${port}:${port}/tcp`,
|
// Der LAN-Reiter von Steam sucht per Broadcast. Die Port-Weiterleitung von Docker reicht Broadcasts
|
||||||
'-p', `${port}:${port}/udp`,
|
// nicht an den Container weiter, deshalb laufen Server auf LAN-Ports direkt im Netz des Hosts.
|
||||||
'-p', `${tvPort}:${tvPort}/udp`,
|
args.push('--network', 'host');
|
||||||
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
|
} else {
|
||||||
);
|
args.push(
|
||||||
|
'-p', `${port}:${port}/tcp`,
|
||||||
|
'-p', `${port}:${port}/udp`,
|
||||||
|
'-p', `${tvPort}:${tvPort}/udp`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
args.push('-v', `${this.options.volume}:/home/steam/cs2-dedicated`);
|
||||||
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
|
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
|
||||||
args.push(this.options.image);
|
args.push(this.options.image);
|
||||||
|
|
||||||
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
|
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
|
||||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
// Das erste "docker run" kann das Image laden und dauert dann länger.
|
// Das erste "docker run" kann das Image laden und dauert dann länger.
|
||||||
await this.docker(args, 15 * 60_000);
|
try {
|
||||||
|
await this.docker(args, 15 * 60_000);
|
||||||
|
} catch (err) {
|
||||||
|
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
||||||
|
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||||
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
|
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
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,
|
host: this.options.host,
|
||||||
port,
|
port,
|
||||||
tvPort,
|
tvPort,
|
||||||
@@ -192,3 +282,199 @@ export class DockerServerAdapter {
|
|||||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
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;
|
||||||
|
this.runDocker = runDocker;
|
||||||
|
this.options = options;
|
||||||
|
this.diskCache = new Map();
|
||||||
|
this.diskInflight = new Map();
|
||||||
|
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
|
||||||
|
this.setHosts(options.hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die Host-Liste neu auf (beim Start und wenn der Admin die Hosts ändert). */
|
||||||
|
setHosts(list) {
|
||||||
|
const options = this.options;
|
||||||
|
const hosts = list ?? [{
|
||||||
|
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,
|
||||||
|
lanPorts: h.lanPorts ?? options.lanPorts ?? [],
|
||||||
|
tvPortOffset: options.tvPortOffset ?? 100,
|
||||||
|
};
|
||||||
|
const merged = { ...options, ...host, host: host.publicHost };
|
||||||
|
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
|
||||||
|
Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
|
||||||
|
getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
|
||||||
|
runDocker: this.runDocker,
|
||||||
|
})]));
|
||||||
|
this.diskCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
|
||||||
|
* nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
|
||||||
|
*/
|
||||||
|
async hostDocker(name, args, timeout) {
|
||||||
|
const host = this.hosts.find((h) => h.name === name);
|
||||||
|
if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
|
||||||
|
['docker', ...args].map((a) => `'${a}'`).join(' '),
|
||||||
|
], { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
return this.adapters.get(name).docker(args, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
|
||||||
|
async pullImage() {
|
||||||
|
if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
|
||||||
|
if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
|
||||||
|
const hosts = this.hosts.map((h) => h.name);
|
||||||
|
this.pull = {
|
||||||
|
running: true,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
finishedAt: null,
|
||||||
|
image: this.options.image,
|
||||||
|
results: hosts.map((name) => ({ name, state: 'running', message: null })),
|
||||||
|
};
|
||||||
|
await Promise.all(hosts.map(async (name, i) => {
|
||||||
|
const result = this.pull.results[i];
|
||||||
|
try {
|
||||||
|
await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
|
||||||
|
const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
|
||||||
|
.catch(() => '');
|
||||||
|
Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
|
||||||
|
} catch (err) {
|
||||||
|
Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.pull.running = false;
|
||||||
|
this.pull.finishedAt = Date.now();
|
||||||
|
return this.pull;
|
||||||
|
}
|
||||||
|
|
||||||
|
pullStatus() {
|
||||||
|
return this.pull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
|
||||||
|
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||||
|
*/
|
||||||
|
async diskUsage(name) {
|
||||||
|
const host = this.hosts.find((h) => h.name === name);
|
||||||
|
if (!host?.dockerHost?.startsWith('ssh://')) return null;
|
||||||
|
const cached = this.diskCache.get(name);
|
||||||
|
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
|
||||||
|
if (!this.diskInflight.has(name)) {
|
||||||
|
this.diskInflight.set(name, (async () => {
|
||||||
|
let value = null;
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
|
||||||
|
`df -B1 "$(docker volume inspect ${host.volume} --format '{{with .Options}}{{.device}}{{end}}' 2>/dev/null | grep . || docker volume inspect ${host.volume} --format '{{.Mountpoint}}' 2>/dev/null || echo /var/lib/docker)" 2>/dev/null || df -B1 /var/lib/docker 2>/dev/null || df -B1 /`,
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
value = parseDf(stdout.trim().split('\n').pop());
|
||||||
|
} catch {
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
this.diskCache.set(name, { at: Date.now(), value });
|
||||||
|
this.diskInflight.delete(name);
|
||||||
|
return value;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
return this.diskInflight.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Server noch auf einen Host passen. */
|
||||||
|
freeSlots(host) {
|
||||||
|
return countSlots(host, this.getUsedPorts(host.publicHost));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auslastung pro Host: Anzahl Serverplätze insgesamt und belegt. */
|
||||||
|
hostStats() {
|
||||||
|
return this.hosts.map((h) => {
|
||||||
|
const total = countSlots(h, new Set());
|
||||||
|
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) return adapter;
|
||||||
|
// Server aus der Zeit vor der Host-Liste (Name "default") oder nach einer Umbenennung: über die Adresse finden
|
||||||
|
const byAddress = handle?.rconHost && this.hosts.find((h) => h.rconHost === handle.rconHost || h.publicHost === handle.rconHost);
|
||||||
|
if (byAddress) return this.adapters.get(byAddress.name);
|
||||||
|
throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,107 @@ export class ServerManager {
|
|||||||
this.service.changed(matchId);
|
this.service.changed(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
|
||||||
|
async hostUsage() {
|
||||||
|
if (!this.adapter.hostStats) return [];
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
|
||||||
|
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||||
|
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
const stats = this.adapter.hostStats();
|
||||||
|
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||||
|
return stats.map((h, i) => ({
|
||||||
|
...h,
|
||||||
|
disk: disks[i],
|
||||||
|
matches: rows
|
||||||
|
.filter((r) => r.host === h.publicHost)
|
||||||
|
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
|
||||||
|
activeServers() {
|
||||||
|
return this.db.prepare(`
|
||||||
|
SELECT s.match_id, s.status, s.host, s.handle, m.mode
|
||||||
|
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||||
|
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
|
||||||
|
`).all().map((r) => {
|
||||||
|
let handle = null;
|
||||||
|
try {
|
||||||
|
handle = JSON.parse(r.handle);
|
||||||
|
} catch {
|
||||||
|
// kaputter Handle: Server zählt trotzdem als aktiv
|
||||||
|
}
|
||||||
|
return { ...r, handle, hostName: handle?.hostName ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
|
||||||
|
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
|
||||||
|
*/
|
||||||
|
setDockerHosts(hosts) {
|
||||||
|
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
|
||||||
|
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
|
||||||
|
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
|
||||||
|
for (const server of this.activeServers()) {
|
||||||
|
const name = server.hostName ?? this.adapter.hosts[0]?.name;
|
||||||
|
const old = before.get(name);
|
||||||
|
const next = nextList.find((h) => h.name === name);
|
||||||
|
if (!next) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
|
||||||
|
}
|
||||||
|
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
|
||||||
|
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.adapter.setHosts(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
|
||||||
|
* aktualisiert wurden und welche nicht erreichbar waren.
|
||||||
|
*/
|
||||||
|
async applyLiveSettings(changed) {
|
||||||
|
const keys = new Set(changed);
|
||||||
|
const rows = this.activeServers().filter((r) => r.status === 'configured');
|
||||||
|
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
|
||||||
|
const m = this.config.match;
|
||||||
|
const result = { updated: 0, failed: [] };
|
||||||
|
for (const row of rows) {
|
||||||
|
const wingman = row.mode === 'wingman';
|
||||||
|
const commands = [];
|
||||||
|
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
|
||||||
|
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
|
||||||
|
}
|
||||||
|
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
|
||||||
|
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
|
||||||
|
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
|
||||||
|
if (commands.length === 0) continue;
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(row.handle, commands);
|
||||||
|
result.updated += 1;
|
||||||
|
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
result.failed.push(row.match_id);
|
||||||
|
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
usedPorts() {
|
||||||
const used = new Set();
|
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()) {
|
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||||
@@ -213,7 +314,12 @@ export class ServerManager {
|
|||||||
|
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
try {
|
try {
|
||||||
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
|
const result = await this.adapter.start({
|
||||||
|
matchId,
|
||||||
|
mode: match.mode,
|
||||||
|
secrets,
|
||||||
|
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
||||||
|
});
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
handle: JSON.stringify(result.handle),
|
handle: JSON.stringify(result.handle),
|
||||||
host: result.host,
|
host: result.host,
|
||||||
@@ -265,7 +371,11 @@ export class ServerManager {
|
|||||||
} else if (status.state === 'failed') {
|
} else if (status.state === 'failed') {
|
||||||
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
||||||
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
|
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
|
||||||
this.service.notify('server_error', matchId, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${status.message}`);
|
this.service.notify('server_error', matchId, {
|
||||||
|
title: '⚠️ Serverfehler',
|
||||||
|
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
|
||||||
|
color: 0xdc2626,
|
||||||
|
});
|
||||||
} else if (status.state === 'stopped') {
|
} else if (status.state === 'stopped') {
|
||||||
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
|
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
|
||||||
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
|
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
|
||||||
@@ -304,7 +414,8 @@ export class ServerManager {
|
|||||||
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
||||||
|
|
||||||
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
||||||
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||||||
|
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||||||
|
|
||||||
const plan = buildMapCommands({
|
const plan = buildMapCommands({
|
||||||
@@ -388,7 +499,17 @@ export class ServerManager {
|
|||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
}
|
}
|
||||||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||||||
this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`);
|
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
||||||
|
const fields = [];
|
||||||
|
if (this.config.match.tvEnabled && row.tv_port) {
|
||||||
|
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
|
||||||
|
}
|
||||||
|
this.service.notify('match_start', viewer.matchId, {
|
||||||
|
title: '🔴 Match läuft',
|
||||||
|
description: `**${this.service.matchTitle(viewer.matchId)}**`,
|
||||||
|
color: 0xef4444,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||||||
this.service.changed(viewer.matchId);
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
@@ -404,8 +525,8 @@ export class ServerManager {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
}
|
}
|
||||||
|
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
|
||||||
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
||||||
this.service.changed(viewer.matchId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||||
@@ -449,7 +570,7 @@ export class ServerManager {
|
|||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (!row || row.status === 'stopped') return;
|
if (!row || row.status === 'stopped') return;
|
||||||
if (row.handle) await this.safeStop(row);
|
if (row.handle) await this.safeStop(row);
|
||||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
|
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
|||||||
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||||
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
||||||
assert.equal(env.CS2_PORT, '27100');
|
assert.equal(env.CS2_PORT, '27100');
|
||||||
assert.equal(env.TV_PORT, '27101');
|
assert.equal(env.TV_PORT, '27200');
|
||||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
assert.equal(r.host, '192.168.178.111');
|
assert.equal(r.host, '192.168.178.111');
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ test('Zweiter Container startet erst, wenn der erste seinen Update-Check abgesch
|
|||||||
d.finishUpdate(first.handle.container);
|
d.finishUpdate(first.handle.container);
|
||||||
const r2 = await second;
|
const r2 = await second;
|
||||||
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
assert.equal(r2.port, 27102, 'zweiter Container bekommt die nächsten Ports');
|
assert.equal(r2.port, 27101, 'zweiter Container bekommt den nächsten Port');
|
||||||
d.finishUpdate(r2.handle.container);
|
d.finishUpdate(r2.handle.container);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 weder per Name noch per Adresse bekannt ist, wird abgelehnt
|
||||||
|
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht', rconHost: '10.9.9.9' }), /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'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Auslastung pro Host: Serverplätze insgesamt und belegt', () => {
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||||
|
assert.deepEqual(p.hostStats(), [
|
||||||
|
{ name: 'a', publicHost: '10.0.0.1', total: 4, used: 1 },
|
||||||
|
{ name: 'b', publicHost: '10.0.0.2', total: 40, used: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => {
|
||||||
|
const { parseDf } = await import('../src/servers/dockerAdapter.js');
|
||||||
|
assert.deepEqual(parseDf('/dev/sda1 1000000000000 400000000000 600000000000 40% /var/lib/docker'), {
|
||||||
|
totalBytes: 1000000000000,
|
||||||
|
freeBytes: 600000000000,
|
||||||
|
});
|
||||||
|
assert.equal(parseDf('kaputt'), null);
|
||||||
|
});
|
||||||
|
test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtigen Host zugeordnet', async () => {
|
||||||
|
const docker = fakeDocker();
|
||||||
|
const p = new DockerHostPool(options([
|
||||||
|
{ ...hostA, rconHost: '10.0.0.1' },
|
||||||
|
{ ...hostB, rconHost: '10.0.0.2' },
|
||||||
|
]), { runDocker: docker.run });
|
||||||
|
await p.stop({ container: 'cs2mv-alt', hostName: 'default', rconHost: '10.0.0.2', port: 27100 });
|
||||||
|
assert.deepEqual(docker.calls.at(-1), ['rm', '-f', 'cs2mv-alt']);
|
||||||
|
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
|
||||||
|
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
||||||
|
});
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
|
||||||
|
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
|
||||||
|
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
|
||||||
|
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
|
||||||
|
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
|
||||||
|
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
|
||||||
|
10/25/2026 - 18:07:52.396 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
|
||||||
|
10/25/2026 - 18:07:52.396 - "name": "round_stats",
|
||||||
|
10/25/2026 - 18:07:52.396 - "round_number" : "1",
|
||||||
|
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
|
||||||
|
10/25/2026 - 18:07:52.396 - }}JSON_END
|
||||||
|
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
|
||||||
|
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
|
||||||
|
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
|
||||||
|
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||||
|
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:08:33.541 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
||||||
|
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||||
|
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
|
||||||
|
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
|
||||||
|
10/25/2026 - 18:09:17.339 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
||||||
|
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
|
||||||
|
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||||
|
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:38.935 - Starting Freeze period
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
||||||
|
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
|
||||||
|
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
|
||||||
|
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||||
|
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
|
||||||
|
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
||||||
|
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
|
||||||
|
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
|
||||||
|
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
|
||||||
|
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
|
||||||
|
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
|
||||||
|
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
|
||||||
|
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
|
||||||
|
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { formatPortList, parsePortList } from '../src/config.js';
|
||||||
|
import { countSlots, DockerHostPool, planPorts } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const host = { portRangeStart: 27100, portRangeEnd: 27105, lanPorts: [27015, 27016] };
|
||||||
|
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
||||||
|
|
||||||
|
test('Portlisten werden gelesen und kompakt geschrieben', () => {
|
||||||
|
assert.deepEqual(parsePortList('27015-27017, 27020'), [27015, 27016, 27017, 27020]);
|
||||||
|
assert.deepEqual(parsePortList(''), []);
|
||||||
|
assert.throws(() => parsePortList('abc'), /kein Port/);
|
||||||
|
assert.throws(() => parsePortList('80-90'), /ungültig/);
|
||||||
|
assert.equal(formatPortList([27020, 27015, 27016, 27017]), '27015-27017,27020');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spielport kommt zuerst aus den LAN-Ports, CSTV liegt immer 100 darüber', () => {
|
||||||
|
assert.deepEqual(planPorts(host, new Set()), { port: 27015, tvPort: 27115 });
|
||||||
|
assert.deepEqual(planPorts(host, new Set([27015, 27115])), { port: 27016, tvPort: 27116 });
|
||||||
|
// LAN-Ports voll: weiter im normalen Bereich, CSTV ebenfalls +100
|
||||||
|
assert.deepEqual(planPorts(host, new Set([27015, 27016, 27115, 27116])), { port: 27100, tvPort: 27200 });
|
||||||
|
// Spielport frei, aber sein CSTV-Port belegt: nächster Spielport
|
||||||
|
assert.deepEqual(planPorts({ ...host, lanPorts: [] }, new Set([27200])), { port: 27101, tvPort: 27201 });
|
||||||
|
assert.equal(planPorts({ ...host, lanPorts: [] }, new Set([27100, 27101, 27102, 27103, 27104, 27105])), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abstand zwischen Spiel- und CSTV-Port ist einstellbar', () => {
|
||||||
|
assert.deepEqual(planPorts({ ...host, tvPortOffset: 5 }, new Set()), { port: 27015, tvPort: 27020 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plätze pro Host: 2 LAN-Ports plus 6 Spielports im normalen Bereich', () => {
|
||||||
|
assert.equal(countSlots(host, new Set()), 8);
|
||||||
|
assert.equal(countSlots({ ...host, lanPorts: [] }, new Set()), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakePool({ busy = [] } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const runDocker = async (args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === 'run') {
|
||||||
|
const ports = args.filter((a, i) => args[i - 1] === '-p').map((a) => Number(a.split(':')[0]));
|
||||||
|
const portEnv = args.find((a) => a.startsWith('CS2_PORT='));
|
||||||
|
if (args.includes('--network') && portEnv) ports.push(Number(portEnv.split('=')[1]));
|
||||||
|
if (ports.some((p) => busy.includes(p))) {
|
||||||
|
const err = new Error('Command failed');
|
||||||
|
err.stderr = 'Bind for 0.0.0.0:27015 failed: port is already allocated';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args[0] === 'inspect') return 'running';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const p = new DockerHostPool({
|
||||||
|
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
|
||||||
|
portRangeStart: 27100, portRangeEnd: 27139, gslt: '', volume: 'v', extraArgs: '',
|
||||||
|
startTimeoutMinutes: 1, lanPorts: [27015, 27016], serverNamePrefix: 'MaxLAN',
|
||||||
|
hosts: [{ name: 'a', publicHost: '10.0.0.1' }],
|
||||||
|
}, { runDocker });
|
||||||
|
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
|
||||||
|
return { pool: p, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Servername zeigt die Teams, Spielport ist ein LAN-Port', async () => {
|
||||||
|
const { pool: p, calls } = fakePool();
|
||||||
|
const r = await p.start({ matchId: 'm1', mode: 'wingman', secrets, teamNames: { A: 'Rote "Füchse"', B: 'Blau' } });
|
||||||
|
assert.equal(r.port, 27015);
|
||||||
|
assert.equal(r.tvPort, 27115);
|
||||||
|
const run = calls.find((c) => c[0] === 'run');
|
||||||
|
assert.ok(run.includes('CS2_SERVERNAME=MaxLAN | Rote Füchse vs. Blau'));
|
||||||
|
// Auf LAN-Ports im Host-Netz, damit Broadcasts des Serverbrowsers ankommen; keine Port-Weiterleitung
|
||||||
|
assert.equal(run[run.indexOf('--network') + 1], 'host');
|
||||||
|
assert.ok(!run.includes('-p'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server außerhalb der LAN-Ports laufen weiter mit Port-Weiterleitung', async () => {
|
||||||
|
const { pool: p, calls } = fakePool();
|
||||||
|
await p.start({ matchId: 'x1', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
await p.start({ matchId: 'x2', mode: 'wingman', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
calls.length = 0;
|
||||||
|
// Die App kennt ihre Ports aus der Datenbank; hier sind beide LAN-Ports belegt
|
||||||
|
const adapter = p.adapters.get('a');
|
||||||
|
adapter.getUsedPorts = () => new Set([27015, 27016, 27115, 27116]);
|
||||||
|
await adapter.launch({ matchId: 'x3', mode: 'wingman', secrets });
|
||||||
|
const run = calls.find((c) => c[0] === 'run');
|
||||||
|
assert.ok(!run.includes('--network'));
|
||||||
|
assert.ok(run.includes('27100:27100/udp'));
|
||||||
|
assert.ok(run.includes('27200:27200/udp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ist ein LAN-Port von einem fremden Dienst belegt, nimmt der Start den nächsten', async () => {
|
||||||
|
const { pool: p, calls } = fakePool({ busy: [27015] });
|
||||||
|
const r = await p.start({ matchId: 'm2', mode: 'competitive', secrets, teamNames: { A: 'A', B: 'B' } });
|
||||||
|
assert.equal(r.port, 27016);
|
||||||
|
assert.equal(calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
|
});
|
||||||
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
|
|||||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Echte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||||
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { openDatabase } from '../src/db.js';
|
||||||
|
import { ServerSettings } from '../src/serverSettings.js';
|
||||||
|
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const config = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] }, vetoStepSeconds: 30 });
|
||||||
|
const db = openDatabase(':memory:');
|
||||||
|
return { config, db, settings: new ServerSettings({ db, config }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Geänderte Einstellungen landen in der Konfiguration und überstehen einen Neustart', () => {
|
||||||
|
const { config, db, settings } = setup();
|
||||||
|
const changed = settings.update({ maxRoundsCompetitive: 30, extraCommands: 'mp_freezetime 15; sv_cheats 0', vetoStepSeconds: 30 });
|
||||||
|
assert.deepEqual(changed, ['maxRoundsCompetitive', 'extraCommands']);
|
||||||
|
assert.equal(config.match.maxRoundsCompetitive, 30);
|
||||||
|
assert.deepEqual(config.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
|
||||||
|
|
||||||
|
// Neustart: neue Konfiguration aus der .env, gespeicherte Werte werden wieder darübergelegt
|
||||||
|
const fresh = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] } });
|
||||||
|
new ServerSettings({ db, config: fresh }).load();
|
||||||
|
assert.equal(fresh.match.maxRoundsCompetitive, 30);
|
||||||
|
assert.deepEqual(fresh.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ungültige Werte und gesperrte Befehle werden abgelehnt', () => {
|
||||||
|
const { config, settings } = setup();
|
||||||
|
assert.throws(() => settings.update({ maxRoundsCompetitive: 500 }), /ganze Zahl von 2 bis 60/);
|
||||||
|
assert.throws(() => settings.update({ overtime: 'ja' }), /an oder aus/);
|
||||||
|
assert.throws(() => settings.update({ extraCommands: 'rcon_password neu' }), /nicht erlaubt/);
|
||||||
|
assert.throws(() => settings.update({ unbekannt: 1 }), /Unbekannte Einstellung/);
|
||||||
|
// Nichts davon wurde übernommen
|
||||||
|
assert.equal(config.match.maxRoundsCompetitive, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zurück auf die Vorgabe entfernt die gespeicherte Abweichung', () => {
|
||||||
|
const { settings } = setup();
|
||||||
|
settings.update({ maxRoundsCompetitive: 30 });
|
||||||
|
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, true);
|
||||||
|
settings.update({ maxRoundsCompetitive: 24 });
|
||||||
|
assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Docker-Hosts: Prüfung der Liste und Übernahme in den Pool, Startzeit wirkt sofort', () => {
|
||||||
|
const { config, settings } = setup();
|
||||||
|
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', dockerHost: 'ssh://x; rm -rf /' }]), /Ungültiges Docker-Ziel/);
|
||||||
|
assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', portStart: 80, portEnd: 90 }]), /Portbereich/);
|
||||||
|
|
||||||
|
const hosts = settings.parseHosts([
|
||||||
|
{ name: 'a', publicHost: '10.0.0.1', dockerHost: 'ssh://server@10.0.0.1', portStart: 27100, portEnd: 27139 },
|
||||||
|
{ name: 'b', publicHost: '10.0.0.2', dockerHost: 'ssh://b', portStart: 27100, portEnd: 27119 },
|
||||||
|
]);
|
||||||
|
const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, lanPorts: [], hosts: null });
|
||||||
|
pool.setHosts(hosts);
|
||||||
|
assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 40], ['b', 20]]);
|
||||||
|
|
||||||
|
pool.options.startTimeoutMinutes = 45;
|
||||||
|
assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user