Compare commits

...
22 Commits
Author SHA1 Message Date
mregosar 7f7272d09f Merge fix/cstv-verzoegerung in main 2026-11-01 22:07:39 +01:00
mregosar e8e070dfe6 CSTV: Verzögerung wirkt wieder, Server läuft am Serienende bis zum Ende der Übertragung
- Das Image schrieb TV_DELAY (Standard 0) bei jedem Containerstart in alle gamemode_*.cfg, die bei
  jedem Laden einer Map laufen: tv_delay stand danach immer auf 0. Die App gibt TV_DELAY jetzt mit
  und setzt tv_delay zusätzlich nach dem Laden der Map per RCON.
- Am Serienende (automatisch oder von Hand abgeschlossen) bleibt der Server die CSTV-Verzögerung
  plus 15 s an, damit der Stream das Ende zeigt. Abgebrochene Matches stoppen sofort.
- Ingame-Chat: Hinweis, dass der Server für CSTV noch online bleibt und sich dann abschaltet;
  beim Mapwechsel die echte Wartezeit nach der CSTV-Übertragung (tv_delaymapchange).
2026-11-01 22:07:39 +01:00
mregosar 123c2f11d7 Merge feature/ingame-farben in main 2026-11-01 21:45:46 +01:00
mregosar 4bce16d084 Merge fix/deltaticks in main 2026-11-01 21:45:46 +01:00
mregosar 1fddf4d20c Ingame-Hinweise farbig: CT blau, T gold, Sieger grün, Aufwärmphase gelb
Farbcodes kommen nur aus den Textbausteinen; Teamnamen und andere Eingaben bleiben ohne Steuerzeichen. Am Server geprüft, welche Codes CS2 im Chat als Farbe anzeigt.
2026-11-01 21:44:56 +01:00
mregosar 52c07c88b8 CS2: sv_deltaticks_enforce 0 gegen NETWORK_DISCONNECT_BADDELTATICK
Mit dem Standard 2 trennt CS2 Spieler und CSTV, wenn deren Bestätigungen außer der Reihe kommen. Neue Container starten mit +sv_deltaticks_enforce 0, beim Einrichten jeder Map und auf Messer-Servern setzt die App den Wert zusätzlich per RCON.
2026-11-01 21:44:55 +01:00
mregosar 69fcf6d15a Merge feature/ingame-hinweise in main 2026-11-01 21:19:14 +01:00
mregosar ece21db78a Hinweise im Ingame-Chat: Map, Seitenwahl, Serienstand, Match-Start, Map-Ende
Nach dem Laden einer Map schreibt die App per RCON say, welche Map gespielt wird, wer sie gewählt hat, wer als CT und T startet und den Serienstand. Die Hinweise wiederholen sich während der Aufwärmphase jede Minute (höchstens zehnmal). Beim Match-Start und am Map-Ende folgen Start-, Ergebnis- und Serienhinweise.
2026-11-01 21:19:13 +01:00
mregosar 7e17a33c3f Merge fix/schriften-lokal in main 2026-11-01 20:40:11 +01:00
mregosar e384279491 Schriften selbst ausliefern statt von Google Fonts
Barlow und Barlow Condensed kommen als @fontsource-Pakete (5.3.0, OFL) aus dem eigenen Build. Keine Verbindung zu Google mehr (IP der Besucher), und die Schriften laden auch im LAN ohne Internet.
2026-11-01 20:40:11 +01:00
mregosar da54150df6 Merge fix/startseite-ohne-abgebrochene in main 2026-11-01 20:31:20 +01:00
mregosar ea37c23350 Startseite: abgebrochene Matches nicht unter Ergebnisse
Unter Ergebnisse stehen nur noch abgeschlossene Matches mit Ergebnis. Im Admin-Bereich und im Archiv bleiben abgebrochene sichtbar.
2026-11-01 20:31:20 +01:00
mregosar 48241f0784 Merge fix/sicherheit-und-stabilitaet in main 2026-11-01 20:25:45 +01:00
mregosar e18b8b4430 Absicherung: Socket-Eingaben, Geheimnisse in Fehlern, Logparser, Start/Stopp, RCON, Stream-Zugang
- Socket-Nachrichten werden geprüft, Handlerfehler abgefangen, Abfragen pro Verbindung begrenzt
  (vorher beendete stream:subscribe mit null ohne Anmeldung das Backend).
- Fehlermeldungen von Prozessen ohne Befehlszeile und mit geschwärzten Passwörtern/Tokens.
- Logparser wertet nur ganze Systemzeilen nach dem Zeitstempel aus; Chat kann keine Map beenden.
- Verspätet ankommende Starts nach Stopp, Neuanforderung oder Löschen bauen ihren Container ab.
- RCON: kaputte Pakete trennen nur die Verbindung, Befehle ohne Antwort gelten als Fehler.
- Organisatoren bekommen einen Stream-Schlüssel nur für ihr Match statt des globalen STREAM_KEY.
- Rate-Limiter mit regelmäßiger Bereinigung und Obergrenze, alte Admin-Sitzungen werden entfernt.
2026-11-01 20:25:45 +01:00
mregosar 35bdfc1f67 Merge feature/einladungslinks-bei-teams in main 2026-11-01 20:09:36 +01:00
mregosar f64da5ac86 Lobby: Einladungslinks unter dem jeweiligen Team
Der Organisator sieht Code, Link, Kopieren und Neuer Code jetzt unten in der Team-Karte. Im Bereich Einladungen bleiben Zuschauer-, Stream- und Organisator-Links.
2026-11-01 20:09:35 +01:00
mregosar 141c5597c4 Merge feature/lobbys-aufraeumen in main 2026-11-01 19:54:09 +01:00
mregosar bd022e043e Offene Lobbys nach 3 Stunden samt Server löschen (LOBBY_MAX_HOURS)
Der Aufräum-Lauf löscht Lobbys, deren Veto nie gestartet wurde, nach LOBBY_MAX_HOURS (Standard 3, 0 = nie) seit dem Erstellen und stoppt ihren Server. Matches im Veto, im Spiel oder abgeschlossen bleiben unberührt.
2026-11-01 19:54:09 +01:00
mregosar 5503995791 Merge fix/keine-map-abstimmung in main 2026-11-01 19:44:25 +01:00
mregosar be42beb260 Keine Map-Abstimmung nach Spielende (mp_endmatch_votenextmap 0)
Gilt für Match- und Messer-Server. Die nächste Map legt das Veto fest, die App lädt sie selbst.
2026-11-01 19:44:25 +01:00
mregosar cc6e7f65be Merge fix/messerrunde-wiederholen in main 2026-11-01 19:28:41 +01:00
mregosar 1492c280d5 Messerrunde: Einrichten bei Fehlern automatisch wiederholen
Nach einer frischen Installation dauert das erste Laden einer Map oft länger als die Wartezeit. Die App versucht das Einrichten jetzt bis zu dreimal (alle 10 s). Scheitern alle Versuche, zeigt die Lobby den Fehler und dem Organisator den Knopf "Messerrunde neu einrichten".
2026-11-01 19:23:52 +01:00
30 changed files with 1017 additions and 96 deletions

No files matched your search

+2
View File
@@ -21,6 +21,8 @@ TRUST_PROXY=false
SERVER_ADAPTER=demo
SERVER_IDLE_TIMEOUT_MINUTES=30
SERVER_MAX_LIFETIME_HOURS=8
# Offene Lobbys nach so vielen Stunden samt Server löschen (0 = nie)
LOBBY_MAX_HOURS=3
SERVER_STATUS_POLL_SECONDS=5
SERVER_REAPER_INTERVAL_SECONDS=60
+3 -1
View File
@@ -126,6 +126,7 @@ Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server:
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
- **Alte Lobbys:** Eine Lobby, deren Veto nach `LOBBY_MAX_HOURS` (Standard 3 Stunden) seit dem Erstellen nicht gestartet wurde, wird samt Server gelöscht.
Ein gestoppter Server lässt sich vom Organisator neu starten.
@@ -393,7 +394,7 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt.
### Beamer-Diashow
@@ -512,6 +513,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
| `LOBBY_MAX_HOURS` | `3` | offene Lobbys nach so vielen Stunden samt Server löschen; `0` = nie |
| `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus |
| `VETO_STEP_SECONDS` | `30` | Zeit pro Veto-Schritt, danach wird automatisch gewählt; `0` = ohne Frist |
| `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie |
-6
View File
@@ -6,12 +6,6 @@
<meta name="theme-color" content="#000000" />
<title>CS2 Mapveto – Maxlan e.V.</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23000'/><path d='M8 6l16 20M24 6L8 26' stroke='%23a72a1c' stroke-width='5' stroke-linecap='round'/></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
+38 -26
View File
@@ -1,40 +1,52 @@
import CopyField from './CopyField.jsx';
function regenerate(act, target, label) {
if (!window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`)) return;
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
}
/** Einladungslink eines Teams, steht für den Organisator unten in der Team-Karte. */
export function TeamInvite({ state, side, act, busy }) {
const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status);
const name = state.teams[side].name;
return (
<div className={`invite team-invite team-${side}`}>
<div className="invite-head">
<strong>Einladung</strong>
<span className="invite-code">{codes[side]}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, side, name)}>Neuer Code</button>}
</div>
<CopyField value={links[side]} />
<p className="small muted">Führt nur in {name}. Nur an dieses Team weitergeben.</p>
</div>
);
}
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status);
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
const regen = (target, label) =>
window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) &&
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
const label = 'Zuschauer (Stream, Kommentatoren)';
return (
<section className="panel organizer-panel">
<h2>Einladungen</h2>
<p className="small muted">Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.</p>
{[
['A', state.teams.A.name, 'team-A'],
['B', state.teams.B.name, 'team-B'],
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'],
].map(([key, label, cls]) => (
<div key={key} className={`invite ${cls}`}>
<div className="invite-head">
<strong>{label}</strong>
<span className="invite-code">{codes[key]}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
</div>
<CopyField value={links[key]} />
{key === 'spectator' && (
<div className="stream-link">
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
</div>
)}
<p className="small muted">Die Einladungslinks der Teams stehen unten in der jeweiligen Team-Karte.</p>
<div className="invite spectator">
<div className="invite-head">
<strong>{label}</strong>
<span className="invite-code">{codes.spectator}</span>
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, 'spectator', label)}>Neuer Code</button>}
</div>
))}
<CopyField value={links.spectator} />
<div className="stream-link">
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
</div>
</div>
<details className="org-link">
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
+2
View File
@@ -1,3 +1,4 @@
import { TeamInvite } from './OrganizerPanel.jsx';
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
const isOwnTeam = viewer.team === side;
const inLobby = state.status === 'lobby';
@@ -59,6 +60,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
</div>
)}
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
{isOrg && state.organizer && <TeamInvite state={state} side={side} act={act} busy={busy} />}
</section>
);
}
+7
View File
@@ -2,6 +2,13 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
// Schriften aus dem eigenen Build statt von Google Fonts: keine Verbindung zu Google, funktioniert auch ohne Internet
import '@fontsource/barlow/latin-400.css';
import '@fontsource/barlow/latin-500.css';
import '@fontsource/barlow/latin-600.css';
import '@fontsource/barlow-condensed/latin-500.css';
import '@fontsource/barlow-condensed/latin-600.css';
import '@fontsource/barlow-condensed/latin-700.css';
import './styles.css';
createRoot(document.getElementById('root')).render(
+10 -1
View File
@@ -235,7 +235,16 @@ function OwnKnifeServer({ state, isOrg, act, busy }) {
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
</p>
{!knife.ready ? (
{knife.failed ? (
<>
<p className="small tone-error">{server.message}</p>
{isOrg ? (
<button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Messerrunde wird neu eingerichtet')}>
Messerrunde neu einrichten
</button>
) : <p className="small muted">Der Organisator kann die Messerrunde neu einrichten.</p>}
</>
) : !knife.ready ? (
<p className="small tone-pending">
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
{server.message && <span className="muted"> ({server.message})</span>}
+2 -1
View File
@@ -12,7 +12,8 @@ export default function Overview() {
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
const running = (matches ?? []).filter((m) => m.group === 'running');
const open = (matches ?? []).filter((m) => m.group === 'open');
const closed = (matches ?? []).filter((m) => m.group === 'closed');
// Nur echte Ergebnisse: abgebrochene Matches haben keins und stehen nicht auf der Startseite
const closed = (matches ?? []).filter((m) => m.group === 'closed' && m.status === 'finished');
return (
<div className="overview home">
+4
View File
@@ -775,3 +775,7 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
.knife-connect > :first-child { flex: 1 1 260px; }
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
.team-invite { margin-top: 12px; padding-right: 0; }
.team-invite .copy-row { flex-wrap: wrap; }
.team-invite .copy-row code { flex: 1 1 100%; font-size: .78rem; white-space: normal; word-break: break-all; overflow: visible; }
.team-invite p { margin: 6px 0 0; }
+20
View File
@@ -23,6 +23,8 @@
"name": "@cs2-mapveto/client",
"version": "0.1.0",
"dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
@@ -725,6 +727,24 @@
"node": ">=12"
}
},
"node_modules/@fontsource/barlow": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz",
"integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/barlow-condensed": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz",
"integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+2
View File
@@ -66,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
}
const token = randomToken();
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
// Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst
for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t);
sessions.set(token, expiresAt);
return res.json({ token, expiresAt });
});
+2
View File
@@ -105,6 +105,8 @@ export function loadConfig(overrides = {}) {
serverAdapter: str('SERVER_ADAPTER', 'demo'),
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
// Offene Lobbys (Veto nie gestartet) werden nach so vielen Stunden gelöscht. 0 = nie
lobbyMaxHours: int('LOBBY_MAX_HOURS', 3),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
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
+4 -4
View File
@@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
}
return service.streamOverview();
// Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : '');
if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
return service.streamOverview({ matchId: access.matchId });
}));
router.post('/matches', limit('create'), wrap((req) => {
+18 -7
View File
@@ -9,16 +9,26 @@
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
const RE = {
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
warmupStart: /World triggered "Warmup_Start"/,
worldRoundEnd: /World triggered "Round_End"/,
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
warmupStart: /^World triggered "Warmup_Start"$/,
worldRoundEnd: /^World triggered "Round_End"$/,
teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/,
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
};
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
export function logPayload(line) {
return String(line).trim().replace(PREFIX, '');
}
// Gründe für ein Rundenende aus den SFUI-Hinweisen
const REASONS = {
Target_Bombed: 'bomb',
@@ -85,7 +95,8 @@ export function applyLogLines(state, lines, ctx) {
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
};
for (const line of lines) {
for (const raw of lines) {
const line = logPayload(raw);
let m;
if ((m = RE.matchStart.exec(line))) {
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
+35 -4
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
import crypto from 'node:crypto';
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.js';
@@ -696,6 +697,10 @@ export class MatchService extends EventEmitter {
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]);
// Gelegentlich alte Einträge anderer Spieler entfernen
if (this.chatRate.size > 500) {
for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k);
}
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(`
@@ -823,8 +828,30 @@ export class MatchService extends EventEmitter {
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten.
*/
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
/** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
matchStreamKey(matchId) {
const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22);
return `${matchId}.${mac}`;
}
/**
* Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang.
* Ohne STREAM_KEY ist die Übersicht offen.
*/
streamAccess(key) {
if (!this.config.streamKey) return { matchId: null };
const value = String(key ?? '');
if (safeEqual(value, this.config.streamKey)) return { matchId: null };
const dot = value.lastIndexOf('.');
const matchId = dot > 0 ? value.slice(0, dot) : '';
if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId };
return null;
}
streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) {
const rows = matchId
? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId)
: this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const now = this.now();
const active = [];
const upcoming = [];
@@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter {
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`,
},
};
}
@@ -1070,7 +1097,11 @@ export class MatchService extends EventEmitter {
};
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
if (match.status === 'lobby' && match.veto_start === 'knife') view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null };
if (match.status === 'lobby' && match.veto_start === 'knife') {
// failed: alle automatischen Versuche fehlgeschlagen, der Organisator kann es von Hand wiederholen
const failed = !knife && row.status === 'ready' && String(row.message ?? '').startsWith('Messerrunde konnte nicht');
view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null, failed };
}
if (viewer.isOrganizer) {
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
view.requestedAt = row.started_at ?? null;
+47 -11
View File
@@ -1,5 +1,8 @@
import { Server } from 'socket.io';
import { safeEqual } from './security.js';
// Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde
const SOCKET_BURST = 20;
const SOCKET_REFILL = 2;
const SOCKET_REFILL_MS = 1000;
/**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
@@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) {
let streamTimer = null;
const broadcastStream = () => {
clearTimeout(streamTimer);
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
streamTimer = setTimeout(async () => {
io.to('stream-overview').emit('stream:overview', service.streamOverview());
// Zugänge mit Match-Schlüssel bekommen nur ihr Match
for (const s of await io.in('stream-match').fetchSockets()) {
s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch }));
}
}, 200);
};
service.on('changed', (matchId) => {
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
});
io.on('connection', (socket) => {
socket.on('overview:subscribe', () => {
// Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden,
// und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand).
let budget = SOCKET_BURST;
const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS);
const on = (event, handler) => socket.on(event, (payload) => {
if (budget <= 0) {
socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' });
return;
}
budget -= 1;
try {
handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {});
} catch (err) {
console.error(`[socket] ${event}: ${err.message}`);
}
});
on('overview:subscribe', () => {
socket.join('overview');
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('stream:subscribe', ({ key } = {}) => {
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
on('stream:subscribe', ({ key }) => {
const access = service.streamAccess(typeof key === 'string' ? key : '');
if (!access) {
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return;
}
socket.join('stream-overview');
socket.emit('stream:overview', service.streamOverview());
socket.leave('stream-overview');
socket.leave('stream-match');
// Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches
socket.data.streamMatch = access.matchId ?? null;
socket.join(access.matchId ? 'stream-match' : 'stream-overview');
socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId }));
});
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
on('match:subscribe', ({ matchId, token, playerToken }) => {
if (typeof matchId !== 'string' || matchId.length > 64) return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
const str = (v) => (typeof v === 'string' ? v : null);
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
try {
service.getMatch(matchId);
} catch (err) {
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
};
socket.on('disconnect', () => {
if (socket.data?.matchId) leave(socket.data.matchId);
clearInterval(refill);
try {
if (socket.data?.matchId) leave(socket.data.matchId);
} catch (err) {
console.error(`[socket] disconnect: ${err.message}`);
}
});
});
+28 -6
View File
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
/**
* Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile
* in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die
* Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt,
* ebenso alle in `secrets` genannten Werte.
*/
export function safeErrorMessage(err, secrets = []) {
const stderr = String(err?.stderr ?? '').trim();
let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler');
text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen';
text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***');
for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***');
return text.slice(0, 500);
}
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
export function createRateLimiter({ windowMs, max }) {
export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
const hits = new Map();
return function check(key) {
let lastSweep = Date.now();
function check(key) {
const now = Date.now();
// Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist
if (now - lastSweep > windowMs) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
lastSweep = now;
}
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
// Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge)
if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value);
hits.set(key, { start: now, count: 1 });
return true;
}
entry.count += 1;
if (hits.size > 10_000) {
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
}
return entry.count <= max;
};
}
check.size = () => hits.size;
return check;
}
+137
View File
@@ -0,0 +1,137 @@
// Hinweise für den Ingame-Chat (RCON "say"). Absender ist im Spiel "Console".
// Reine Textbausteine ohne Server-Zugriff, damit sie sich mit Beispielen testen lassen.
const MAX_LENGTH = 120;
/**
* Farbcodes für den CS2-Chat (am Server geprüft). Ein Code färbt alles bis zum nächsten Code.
* Sie kommen ausschließlich aus den Textbausteinen hier, nie aus Eingaben.
*/
export const COLOR = {
white: '\x01',
red: '\x02',
green: '\x04',
yellow: '\x09',
blue: '\x0B',
gold: '\x10',
};
const ALLOWED_CODES = new Set(Object.values(COLOR));
// CS2 färbt wie im Spiel: CT blau, T gold
const SIDE_COLOR = { CT: COLOR.blue, T: COLOR.gold };
/**
* Eingaben (Teamnamen, Map-Namen) für den Chat: ohne Anführungszeichen, Semikolon, Zeilenumbrüche und ohne
* Steuerzeichen, sodass sich weder Befehle anhängen noch eigene Farben einbauen lassen.
* Doppelpunkt und Komma bleiben für Spielstände erlaubt.
*/
export function chatText(text, maxLength = MAX_LENGTH) {
return String(text ?? '')
.replace(/["';\\\r\n\t]/g, '')
.replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
/** Dauer für den Chat: unter zwei Minuten in Sekunden, sonst gerundet in Minuten. */
function duration(seconds) {
const s = Math.max(0, Math.round(seconds));
return s < 120 ? `${s} s` : `etwa ${Math.round(s / 60)} Minuten`;
}
/** Gefärbter Teil einer Zeile; danach geht es in Weiß weiter. */
const paint = (color, text) => `${color}${text}${COLOR.white}`;
/**
* Konsolenbefehle für eine Liste von Hinweisen. Erlaubt sind nur die Farbcodes aus COLOR; alles, was einen
* weiteren Befehl einleiten könnte, fällt weg. Das Leerzeichen am Anfang braucht CS2, damit der erste Farbcode wirkt.
*/
export function sayCommands(lines) {
return lines
// Kein trim(): Gelb (\x09) und Blau (\x0B) zählen für JavaScript als Leerraum und gingen sonst verloren
.map((line) => [...String(line ?? '')]
.filter((ch) => ALLOWED_CODES.has(ch) || !/[\x00-\x1F\x7F"';\\]/.test(ch))
.join(''))
.filter((line) => line.replace(/[\x00-\x1F]/g, '').trim())
.map((line) => `say ${line}`);
}
const other = (team) => (team === 'A' ? 'B' : 'A');
const name = (teams, team) => chatText(teams[team], 32) || 'Team';
/** Teamname in der Farbe seiner Startseite. */
const sided = (teams, team, ctTeam) => (ctTeam ? paint(SIDE_COLOR[team === ctTeam ? 'CT' : 'T'], name(teams, team)) : name(teams, team));
/**
* Hinweise nach dem Laden einer Map: welche Map, wer sie gewählt hat, wer welche Seite startet,
* Serienstand und dass die Aufwärmphase bis zur Freigabe läuft.
* @param {object} p
* @param {{A: string, B: string}} p.teams Teamnamen
* @param {{number: number, mapName: string, pickedBy: 'A'|'B'|null, sideChosenBy: 'A'|'B'|null, ctTeam: 'A'|'B'|null}} p.map
* @param {number} p.total Maps in der Serie
* @param {{A: number, B: number}} p.series Serienstand vor dieser Map
*/
export function mapLoadedLines({ teams, map, total, series }) {
const lines = [];
const mapName = chatText(map.mapName, 32);
const head = total > 1 ? `Map ${map.number} von ${total}: ${mapName}` : `Map: ${mapName}`;
lines.push(map.pickedBy ? `${head} - gewählt von ${name(teams, map.pickedBy)}` : `${head} - Entscheidungsmap`);
if (map.ctTeam) {
const t = other(map.ctTeam);
const chooser = map.sideChosenBy ? `${name(teams, map.sideChosenBy)} hat die Seite gewählt: ` : '';
lines.push(`${chooser}${sided(teams, map.ctTeam, map.ctTeam)} startet als ${paint(COLOR.blue, 'CT')}, ${sided(teams, t, map.ctTeam)} als ${paint(COLOR.gold, 'T')}`);
}
if (total > 1) lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
lines.push(paint(COLOR.yellow, 'Aufwärmphase - das Match startet, sobald der Organisator es freigibt'));
return lines;
}
/** Hinweis beim Start des Matches (Aufwärmphase beendet). */
export function matchStartLines({ teams, ctTeam }) {
const live = paint(COLOR.green, 'Match live!');
if (!ctTeam) return [`${live} ${name(teams, 'A')} gegen ${name(teams, 'B')} - viel Glück, have fun`];
return [`${live} ${sided(teams, ctTeam, ctTeam)} (CT) gegen ${sided(teams, other(ctTeam), ctTeam)} (T) - viel Glück, have fun`];
}
/**
* Hinweise am Ende einer Map: Sieger mit Stand, Serienstand und was als Nächstes passiert.
* @param {object} p
* @param {{mapName: string, number: number, winner: 'A'|'B'|null, score: {A: number, B: number}}} p.map
* @param {{A: number, B: number}} p.series Serienstand nach dieser Map
* @param {'A'|'B'|null} p.decided Sieger der Serie, wenn entschieden
* @param {string|null} p.nextMapName nächste Map, wenn es eine gibt
* @param {number} p.autoAdvanceSeconds Wartezeit bis zum automatischen Laden (< 0 = aus)
* @param {number} p.total Maps in der Serie
* @param {number} [p.tvDelaySeconds] CSTV-Verzögerung (0 = keine). CS2 wechselt die Map erst, wenn CSTV durch ist
* (tv_delaymapchange), und am Serienende läuft der Server so lange weiter.
* @param {number} [p.shutdownSeconds] am Serienende: so lange läuft der Server noch (null = unbekannt)
*/
export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total, tvDelaySeconds = 0, shutdownSeconds = null }) {
const lines = [];
const score = `${map.score.A}:${map.score.B}`;
const mapName = chatText(map.mapName, 32);
lines.push(map.winner
? `Map ${map.number} (${mapName}) geht an ${paint(COLOR.green, name(teams, map.winner))} mit ${score}`
: `Map ${map.number} (${mapName}) endet ${score}`);
if (decided) {
const s = decided === 'A' ? `${series.A}:${series.B}` : `${series.B}:${series.A}`;
lines.push(total > 1
? `${paint(COLOR.green, `${name(teams, decided)} gewinnt die Serie ${s}`)} - danke fürs Spielen!`
: 'Danke fürs Spielen!');
if (tvDelaySeconds > 0) {
lines.push(shutdownSeconds
? `Der Server bleibt noch ${duration(shutdownSeconds)} für die CSTV-Übertragung online und schaltet sich dann ab.`
: 'Der Server bleibt für die CSTV-Übertragung noch online und schaltet sich dann ab.');
}
} else if (nextMapName) {
const next = chatText(nextMapName, 32);
lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
// Die Map wechselt frühestens nach der Wartezeit der App und erst, wenn CSTV das Ende übertragen hat
const wait = Math.max(autoAdvanceSeconds, tvDelaySeconds > 0 ? tvDelaySeconds : 0);
const viaTv = tvDelaySeconds > autoAdvanceSeconds ? ' (nach der CSTV-Übertragung)' : '';
lines.push(autoAdvanceSeconds >= 0
? `Nächste Map: ${next}, wird in ${viaTv && wait < 120 ? 'etwa ' : ''}${duration(wait)} geladen${viaTv} - bleibt auf dem Server`
: `Nächste Map: ${next} - bleibt auf dem Server`);
}
return lines;
}
+6
View File
@@ -59,6 +59,12 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
'sv_deltaticks_enforce 0',
// Nach dem Laden noch einmal: die Spielmodus-Datei der Map setzt tv_delay beim Laden zurück
`tv_delay ${Number(settings.tvDelaySeconds)}`,
'mp_autoteambalance 0',
'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
+20 -7
View File
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js';
import { safeErrorMessage } from '../security.js';
const exec = promisify(execFile);
@@ -149,8 +150,16 @@ export class DockerServerAdapter {
async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args, this.options.name);
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();
try {
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
} catch (err) {
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
const clean = new Error(safeErrorMessage(err));
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
clean.code = err.code;
throw clean;
}
}
allocatePorts(blocked = new Set()) {
@@ -239,11 +248,11 @@ export class DockerServerAdapter {
}
}
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
async launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, 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, serverName }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
}
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
@@ -257,9 +266,13 @@ export class DockerServerAdapter {
CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0',
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
// -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
// sv_deltaticks_enforce 0: sonst trennt CS2 Spieler und CSTV mit NETWORK_DISCONNECT_BADDELTATICK
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files +sv_deltaticks_enforce 0',
TV_ENABLE: '1',
// Das Image schreibt TV_DELAY beim Start in alle gamemode_*.cfg (Standard 0), und die laufen bei jedem
// Laden einer Map. Ohne den Wert hier stünde CSTV nach jedem Mapwechsel wieder ohne Verzögerung da.
TV_DELAY: String(Math.max(0, Number(tvDelaySeconds) || 0)),
TV_PORT: String(tvPort),
TV_PW: secrets.tvPassword,
TV_RELAY_PW: secrets.tvPassword,
@@ -291,7 +304,7 @@ export class DockerServerAdapter {
// 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, serverName }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
}
return {
+15 -3
View File
@@ -3,7 +3,7 @@
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
import { HttpError } from '../matchService.js';
import { randomPassword } from '../security.js';
import { randomPassword, safeErrorMessage } from '../security.js';
const SLOW_POLL_MS = 10_000;
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
@@ -30,6 +30,10 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
'mp_maxrounds 1',
'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
'sv_deltaticks_enforce 0',
'mp_match_end_restart 1',
'mp_match_restart_delay 10',
'mp_halftime 0',
@@ -205,7 +209,14 @@ export class KnifeServers {
mode: row.mode,
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
// Gleicher Wert wie bei den Match-Servern: das Image schreibt ihn in die gemeinsamen Spielmodus-Dateien
tvDelaySeconds: this.config.match?.tvDelaySeconds,
});
// Während des Starts entfernt: Container gleich wieder abbauen
if (!this.row(id)) {
await this.adapter.stop(result.handle).catch(() => {});
return;
}
this.update(id, {
status: 'starting',
message: 'Container läuft, Spielserver lädt',
@@ -217,8 +228,9 @@ export class KnifeServers {
started_at: Date.now(),
});
} catch (err) {
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
const message = safeErrorMessage(err, [row.rcon_password]);
if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` });
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed');
} finally {
this.busy.delete(id);
}
+16 -5
View File
@@ -75,9 +75,17 @@ export class RconClient {
onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer);
this.buffer = rest;
for (const packet of packets) for (const listener of this.listeners) listener(packet);
let decoded;
try {
decoded = decodePackets(this.buffer);
} catch (err) {
// Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden
this.buffer = Buffer.alloc(0);
this.socket?.destroy(new RconError(err.message));
return;
}
this.buffer = decoded.rest;
for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet);
}
authenticate() {
@@ -105,15 +113,18 @@ export class RconClient {
const id = this.nextId++;
return new Promise((resolve, reject) => {
let body = '';
let answered = false;
let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs);
// Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg
const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs);
const listener = (packet) => {
if (packet.id !== id) return;
answered = true;
body += packet.body;
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
};
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => {
clearTimeout(timer);
clearTimeout(quietTimer);
+3 -2
View File
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
try {
await runRcon(target, plan.beforeMapChange);
} catch (err) {
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
// Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
// beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten.
if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
}
const deadline = Date.now() + loadTimeoutMs;
+177 -10
View File
@@ -1,9 +1,11 @@
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
import { mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from './chatAnnouncements.js';
import { seriesScore } from '../liveScore.js';
import { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js';
import { randomPassword, safeErrorMessage } from '../security.js';
export const SERVER_STATUS = {
starting: 'Server startet',
@@ -18,6 +20,14 @@ export const SERVER_STATUS = {
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
const SLOW_POLL_MS = 30_000;
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
const KNIFE_RETRIES = 3;
const KNIFE_RETRY_DELAY_MS = 10_000;
// Hinweise im Ingame-Chat während der Aufwärmphase wiederholen (für Spieler, die später verbinden)
const WARMUP_REPEAT_MS = 60_000;
const WARMUP_REPEATS = 10;
// Nach der CSTV-Verzögerung noch so lange laufen lassen, damit auch der Endstand übertragen wird
const CSTV_GRACE_MS = 15_000;
/**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
@@ -29,6 +39,14 @@ export class ServerManager {
this.busy = new Set();
this.simulator = new DemoMatchSimulator();
this.autoAdvance = new Map();
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
this.knifeAttempts = new Map();
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
this.warmupRepeatMs = WARMUP_REPEAT_MS;
this.warmupTimers = new Map();
// Nach Match-Ende: Server laufen bis zum Ende der CSTV-Übertragung weiter (matchId -> Timer)
this.closeTimers = new Map();
this.cstvGraceMs = CSTV_GRACE_MS;
this.lastPoll = new Map();
this.timers = [];
}
@@ -36,7 +54,7 @@ export class ServerManager {
init() {
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
this.service.on('closed', (id) => this.stopAfterClose(id));
this.service.on('mapEnded', (e) => this.onMapEnded(e));
}
@@ -46,6 +64,7 @@ export class ServerManager {
*/
onMapEnded({ matchId, number, decided, hasNext }) {
const seconds = this.config.autoAdvanceSeconds;
this.announceMapEnd(matchId, number, decided, hasNext);
if (seconds < 0) return;
clearTimeout(this.autoAdvance.get(matchId));
const finish = Boolean(decided) || !hasNext;
@@ -126,12 +145,13 @@ export class ServerManager {
}
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
async deleteMatch(matchId) {
async deleteMatch(matchId, reason = 'Match vom Admin gelöscht') {
this.service.getMatch(matchId);
clearTimeout(this.autoAdvance.get(matchId));
this.autoAdvance.delete(matchId);
this.simulator.stop(matchId);
await this.stop(matchId, 'Match vom Admin gelöscht');
this.knifeAttempts.delete(matchId);
await this.stop(matchId, reason);
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
this.service.emit('changed', matchId);
}
@@ -148,6 +168,10 @@ export class ServerManager {
this.autoAdvance.clear();
this.timers.forEach(clearInterval);
this.timers = [];
for (const t of this.warmupTimers.values()) clearInterval(t);
this.warmupTimers.clear();
for (const t of this.closeTimers.values()) clearTimeout(t);
this.closeTimers.clear();
}
row(matchId) {
@@ -336,13 +360,23 @@ export class ServerManager {
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
const match = this.service.getMatch(matchId);
const attempt = (previous?.attempts ?? 0) + 1;
try {
const result = await this.adapter.start({
matchId,
mode: match.mode,
secrets,
teamNames: { A: match.team_a_name, B: match.team_b_name },
tvDelaySeconds: this.config.match.tvDelaySeconds,
});
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
const current = this.row(matchId);
if (!current || current.status !== 'starting' || current.attempts !== attempt) {
this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`);
await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {});
return;
}
this.update(matchId, {
handle: JSON.stringify(result.handle),
host: result.host,
@@ -351,8 +385,9 @@ export class ServerManager {
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
});
} catch (err) {
this.update(matchId, { status: 'start_failed', message: err.message });
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
const message = safeErrorMessage(err, Object.values(secrets));
if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message });
if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`);
}
} finally {
this.busy.delete(matchId);
@@ -426,6 +461,82 @@ export class ServerManager {
}
}
// ---------- Hinweise im Ingame-Chat ----------
teamNames(matchId) {
const match = this.service.getMatch(matchId);
return { A: match.team_a_name, B: match.team_b_name };
}
currentSeriesMap(matchId) {
const match = this.service.getMatch(matchId);
return this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number) ?? null;
}
/** Schickt Hinweise per "say" an den eingerichteten Server. Fehler stören den Ablauf nie. */
announce(matchId, lines) {
const row = this.row(matchId);
if (!row?.handle || row.status !== 'configured' || !this.adapter.sendCommands || !lines.length) return;
this.adapter.sendCommands(JSON.parse(row.handle), sayCommands(lines))
.catch((err) => this.logger.warn?.(`[chat] Hinweis an ${matchId} nicht gesendet: ${err.message}`));
}
/** Nach dem Laden einer Map: Map, Wahl, Seiten, Serienstand. Wiederholt sich, solange die Aufwärmphase läuft. */
announceMapLoaded(matchId, number) {
const match = this.service.getMatch(matchId);
const maps = this.service.seriesMaps(matchId);
const map = maps.find((m) => m.number === number);
if (!map) return;
const lines = mapLoadedLines({
teams: this.teamNames(matchId),
map: { number, mapName: this.service.mapName(match, map.map), pickedBy: map.picked_by, sideChosenBy: map.side_chosen_by, ctTeam: map.ct_team },
total: maps.length,
series: seriesScore(maps),
});
this.announce(matchId, lines);
this.stopWarmupRepeat(matchId);
let left = WARMUP_REPEATS;
const timer = setInterval(() => {
const current = this.currentSeriesMap(matchId);
const live = current?.live ? JSON.parse(current.live) : null;
if (left-- <= 0 || current?.number !== number || live?.warmupEnded || this.row(matchId)?.status !== 'configured') {
this.stopWarmupRepeat(matchId);
return;
}
this.announce(matchId, lines);
}, this.warmupRepeatMs);
this.warmupTimers.set(matchId, timer);
}
stopWarmupRepeat(matchId) {
clearInterval(this.warmupTimers.get(matchId));
this.warmupTimers.delete(matchId);
}
/** Am Map-Ende: Sieger, Serienstand, nächste Map oder Seriensieger. */
announceMapEnd(matchId, number, decided, hasNext) {
const match = this.service.getMatch(matchId);
const maps = this.service.seriesMaps(matchId);
const map = maps.find((m) => m.number === number);
if (!map) return;
const next = hasNext && !decided ? maps.find((m) => m.number === number + 1) : null;
const tvDelay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
this.announce(matchId, mapEndLines({
teams: this.teamNames(matchId),
map: { number, mapName: this.service.mapName(match, map.map), winner: map.winner, score: { A: map.score_a, B: map.score_b } },
series: seriesScore(maps),
decided,
nextMapName: next ? this.service.mapName(match, next.map) : null,
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
total: maps.length,
tvDelaySeconds: tvDelay,
// Serienende: Match schließt nach der Wartezeit, der Server läuft dann noch CSTV-Verzögerung + Puffer
shutdownSeconds: decided && this.config.autoAdvanceSeconds >= 0 && tvDelay > 0
? this.config.autoAdvanceSeconds + tvDelay + this.cstvGraceMs / 1000
: null,
}));
}
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
wantsKnife(match, row) {
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
@@ -444,10 +555,22 @@ export class ServerManager {
try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
} catch (err) {
this.update(matchId, { knife_at: null, knife_phase: null, message: `Messerrunde konnte nicht eingerichtet werden: ${err.message}` });
// Das erste Laden einer Map dauert nach einer frischen Installation oft länger: von selbst noch einmal versuchen
const attempt = (this.knifeAttempts.get(matchId) ?? 0) + 1;
this.knifeAttempts.set(matchId, attempt);
const again = attempt < KNIFE_RETRIES;
this.update(matchId, {
knife_at: null,
knife_phase: null,
message: again
? `Messerrunde wird erneut eingerichtet (Versuch ${attempt + 1} von ${KNIFE_RETRIES}): ${err.message}`
: `Messerrunde konnte nicht eingerichtet werden: ${err.message}`,
});
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
if (again) this.timers.push(setTimeout(() => this.maybeConfigure(matchId), this.knifeRetryDelayMs));
return;
}
this.knifeAttempts.delete(matchId);
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
} finally {
@@ -517,8 +640,9 @@ export class ServerManager {
try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) {
this.update(matchId, { status: 'config_failed', message: err.message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
this.update(matchId, { status: 'config_failed', message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
return;
}
this.update(matchId, {
@@ -533,6 +657,7 @@ export class ServerManager {
this.service.markCurrentMapLive(matchId);
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
this.announceMapLoaded(matchId, seriesMap.number);
} finally {
this.busy.delete(matchId);
}
@@ -555,6 +680,7 @@ export class ServerManager {
return;
}
if (this.wantsKnife(match, row)) {
this.knifeAttempts.delete(matchId);
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
this.configureKnife(matchId).catch((err) => this.logger.error(err));
return;
@@ -591,6 +717,9 @@ export class ServerManager {
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.stopWarmupRepeat(viewer.matchId);
const current = this.currentSeriesMap(viewer.matchId);
this.announce(viewer.matchId, matchStartLines({ teams: this.teamNames(viewer.matchId), ctTeam: current?.ct_team ?? null }));
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = [];
if (this.config.match.tvEnabled && row.tv_port) {
@@ -657,8 +786,32 @@ export class ServerManager {
}
}
/**
* Match beendet: Lief es bis zum Ende und ist CSTV mit Verzögerung an, bleibt der Server so lange stehen,
* bis die Zuschauer das Ende gesehen haben. CSTV läuft im selben Container und endet mit ihm.
*/
stopAfterClose(matchId) {
const match = this.service.getMatch(matchId);
const row = this.row(matchId);
const delay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
if (match.status !== 'finished' || delay <= 0 || row?.status !== 'configured') {
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
return;
}
const waitMs = delay * 1000 + this.cstvGraceMs;
this.service.log(matchId, 'server', `Server läuft noch ${Math.round(waitMs / 1000)} s weiter, damit CSTV das Ende überträgt`);
clearTimeout(this.closeTimers.get(matchId));
this.closeTimers.set(matchId, setTimeout(() => {
this.closeTimers.delete(matchId);
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
}, waitMs));
}
async stop(matchId, reason) {
this.simulator.stop(matchId);
this.stopWarmupRepeat(matchId);
clearTimeout(this.closeTimers.get(matchId));
this.closeTimers.delete(matchId);
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
@@ -668,15 +821,29 @@ export class ServerManager {
// ---------- Automatisches Herunterfahren ----------
/** Lobbys, die länger als LOBBY_MAX_HOURS offen sind (Veto nie gestartet), samt Server löschen. */
async deleteStaleLobbies(now = Date.now()) {
const hours = this.config.lobbyMaxHours;
if (!hours) return 0;
const stale = this.db.prepare("SELECT id FROM matches WHERE status = 'lobby' AND created_at < ?").all(now - hours * 3_600_000);
for (const { id } of stale) {
if (this.busy.has(id)) continue;
this.logger.log?.(`[aufräumen] Lobby ${id} gelöscht (länger als ${hours} h offen)`);
await this.deleteMatch(id, `Lobby länger als ${hours} h offen`).catch((err) => this.logger.error(err));
}
return stale.length;
}
async reap(now = Date.now()) {
this.service.archiveClosed(now);
await this.deleteStaleLobbies(now);
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
for (const row of rows) {
if (this.busy.has(row.match_id)) continue;
const match = this.service.getMatch(row.match_id);
if (CLOSED_STATUSES.includes(match.status)) {
await this.stop(row.match_id, 'Match beendet');
if (!this.closeTimers.has(row.match_id)) await this.stop(row.match_id, 'Match beendet');
continue;
}
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
+103 -1
View File
@@ -177,7 +177,11 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
return s.status === 'live' && s.currentMapNumber === 2;
}, { label: 'Map 2' });
// Abschließen von Hand: der Server wartet auf das Ende der CSTV-Übertragung (hier kurz gehalten)
app.manager.config.match.tvDelaySeconds = 1;
app.manager.cstvGraceMs = 200;
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
assert.notEqual(app.service.serverRow(id).status, 'stopped', 'läuft für CSTV noch');
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
const list = (await call('GET', '/matches')).body.matches;
assert.equal(list.find((m) => m.id === id).group, 'closed');
@@ -339,9 +343,15 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
assert.equal(app.service.getMatch(id).status, 'live');
// CSTV-Verzögerung kurz halten: der Server soll erst danach gestoppt werden
app.manager.config.match.tvDelaySeconds = 1;
app.manager.cstvGraceMs = 300;
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
assert.equal(app.service.serverRow(id).status, 'configured', 'Server läuft für CSTV noch weiter');
await app.manager.reap();
assert.equal(app.service.serverRow(id).status, 'configured', 'auch der Aufräum-Lauf wartet auf CSTV');
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
@@ -537,6 +547,8 @@ test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist',
// Automatisch: abgebrochenes Match, älter als 3 Tage
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
// Hier geht es nur ums Archiv: das Löschen alter Lobbys (eigener Test) bleibt aus
app.manager.config.lobbyMaxHours = 0;
await app.manager.reap(Date.now() + 4 * 86_400_000);
assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert');
assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
@@ -730,7 +742,7 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
await app.manager.poll();
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup' });
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup', failed: false });
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
const pub = (await call('GET', `/matches/${id}`)).body;
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
@@ -775,3 +787,93 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
await close();
}
});
test('Messerrunde: Einrichten wird bei Fehlern von selbst wiederholt, danach kann der Organisator neu starten', async () => {
const { app, call, close } = await setup();
try {
app.manager.knifeRetryDelayMs = 0;
const configure = app.adapter.configure.bind(app.adapter);
let failures = 2;
app.adapter.configure = async (h, plan) => {
if (failures > 0) { failures -= 1; throw new Error('Map de_mirage wurde nicht rechtzeitig geladen'); }
return configure(h, plan);
};
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
const { id, organizerToken: org } = created.body;
await app.manager.poll();
// Zwei Fehlschläge, der dritte Versuch klappt ohne Zutun
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde nach Wiederholung eingerichtet' });
// Scheitern alle Versuche, bekommt der Organisator den Knopf
failures = 99;
app.manager.update(id, { knife_at: null, knife_phase: null });
app.manager.knifeAttempts.delete(id);
app.manager.maybeConfigure(id);
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.server.knife.failed, { label: 'als fehlgeschlagen gemeldet' });
failures = 0;
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'von Hand neu eingerichtet' });
} finally {
await close();
}
});
test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende Matches nicht', async () => {
const { app, call, close } = await setup();
try {
const make = async () => (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } })).body.id;
const old = await make();
const fresh = await make();
const { id: running, org } = await readyWingmanMatch(call, 'bo1');
assert.equal((await call('POST', `/matches/${running}/veto/start`, { token: org })).status, 200);
const ago = (h) => Date.now() - h * 3_600_000;
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id IN (?, ?)').run(ago(4), old, running);
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id = ?').run(ago(2), fresh);
await app.manager.reap();
const left = app.service.db.prepare('SELECT id FROM matches').all().map((r) => r.id);
assert.ok(!left.includes(old), 'alte Lobby gelöscht');
assert.ok(left.includes(fresh), 'Lobby unter 3 h bleibt');
assert.ok(left.includes(running), 'Match im Veto bleibt');
assert.equal(app.service.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE match_id = ?').get(old).n, 0, 'Server-Datensatz weg');
assert.equal((await call('GET', `/matches/${old}`)).status, 404);
} finally {
await close();
}
});
test('Ingame-Chat: Hinweise nach dem Laden, beim Start und am Map-Ende', async () => {
const { app, call, close } = await setup();
try {
const said = [];
const send = app.adapter.sendCommands.bind(app.adapter);
app.adapter.sendCommands = async (h, commands) => {
said.push(...commands.filter((c) => c.startsWith('say ')).map((c) => c.slice(4).replace(/[-]/g, '').trim()));
return send(h, commands);
};
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
const cap = (t) => tokens[snap.teams[t].players.find((p) => p.isCaptain).nickname];
await playVeto(call, id, { A: cap('A'), B: cap('B') });
await app.manager.poll();
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Map 1 eingerichtet' });
await waitFor(() => said.some((l) => l.startsWith('Map 1 von 3')), { label: 'Hinweis nach dem Laden' });
assert.ok(said.some((l) => / startet als CT, .* als T$/.test(l)), 'Seiten werden genannt');
assert.ok(said.includes('Serienstand: Alpha 0:0 Bravo'));
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
await waitFor(() => said.some((l) => l.startsWith('Match live!')), { label: 'Hinweis beim Start' });
const map = app.service.seriesMaps(id)[0].map;
const key = app.manager.row(id).config_key;
const body = ['Team playing "CT": Alpha', 'Team playing "TERRORIST": Bravo', 'World triggered "Match_Start" on "' + map + '"',
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', 'World triggered "Round_End"',
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`].join('\n');
await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
await waitFor(() => said.some((l) => /^Map 1 \(.+\) geht an Alpha mit 1:0$/.test(l)), { label: 'Hinweis am Map-Ende' });
assert.ok(said.some((l) => l.startsWith('Nächste Map: ')));
} finally {
await close();
}
});
+76
View File
@@ -0,0 +1,76 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chatText, COLOR, mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from '../src/servers/chatAnnouncements.js';
const teams = { A: 'Nordlicht', B: 'Hafenkante' };
// Text ohne Farbcodes, zum Vergleichen des Inhalts
const plain = (lines) => lines.map((l) => l.replace(/[-]/g, ''));
test('Hinweise nach dem Laden: Map, Wahl, Seiten, Serienstand, Aufwärmphase', () => {
const lines = mapLoadedLines({
teams, total: 3, series: { A: 1, B: 0 },
map: { number: 2, mapName: 'Inferno', pickedBy: 'B', sideChosenBy: 'A', ctTeam: 'A' },
});
assert.deepEqual(plain(lines), [
'Map 2 von 3: Inferno - gewählt von Hafenkante',
'Nordlicht hat die Seite gewählt: Nordlicht startet als CT, Hafenkante als T',
'Serienstand: Nordlicht 1:0 Hafenkante',
'Aufwärmphase - das Match startet, sobald der Organisator es freigibt',
]);
const bo1 = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
assert.equal(plain(bo1)[0], 'Map: Nuke - Entscheidungsmap');
assert.ok(!bo1.some((l) => l.startsWith('Serienstand')), 'kein Serienstand im BO1');
});
test('Hinweise bei Match-Start und Map-Ende', () => {
assert.deepEqual(plain(matchStartLines({ teams, ctTeam: 'B' })), ['Match live! Hafenkante (CT) gegen Nordlicht (T) - viel Glück, have fun']);
const next = mapEndLines({
teams, total: 3, decided: null, nextMapName: 'Inferno', autoAdvanceSeconds: 30, series: { A: 1, B: 0 },
map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } },
});
assert.deepEqual(plain(next), [
'Map 1 (Mirage) geht an Nordlicht mit 13:9',
'Serienstand: Nordlicht 1:0 Hafenkante',
'Nächste Map: Inferno, wird in 30 s geladen - bleibt auf dem Server',
]);
const won = mapEndLines({
teams, total: 3, decided: 'B', nextMapName: null, autoAdvanceSeconds: 30, series: { A: 0, B: 2 },
map: { number: 2, mapName: 'Nuke', winner: 'B', score: { A: 5, B: 13 } },
});
assert.equal(plain(won)[1], 'Hafenkante gewinnt die Serie 2:0 - danke fürs Spielen!');
});
test('Chattexte können keine weiteren Befehle anhängen', () => {
assert.equal(chatText('Team"; rcon_password x; say "'), 'Team rcon_password x say');
assert.deepEqual(sayCommands(['Hallo; quit', '', 'Stand 13:9']), ['say Hallo quit', 'say Stand 13:9']);
assert.ok(chatText('x'.repeat(300)).length <= 120);
});
test('Farben: CT blau, T gold, Sieger grün, Aufwärmphase gelb; Eingaben bringen keine eigenen Farben mit', () => {
const loaded = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
const sides = loaded.find((l) => l.includes('startet als'));
assert.ok(sides.includes(`${COLOR.blue}Hafenkante${COLOR.white}`), 'CT-Team blau');
assert.ok(sides.includes(`${COLOR.gold}Nordlicht${COLOR.white}`), 'T-Team gold');
assert.ok(loaded.at(-1).startsWith(COLOR.yellow), 'Aufwärmphase gelb');
assert.ok(matchStartLines({ teams, ctTeam: 'A' })[0].startsWith(`${COLOR.green}Match live!`));
const end = mapEndLines({ teams, total: 1, decided: null, nextMapName: null, autoAdvanceSeconds: 30, series: { A: 1, B: 0 }, map: { number: 1, mapName: 'Nuke', winner: 'A', score: { A: 13, B: 2 } } });
assert.ok(end[0].includes(`${COLOR.green}Nordlicht${COLOR.white}`), 'Sieger grün');
// Farbcodes im Teamnamen werden entfernt, die eigenen Farben bleiben
const evil = matchStartLines({ teams: { A: `BöseRot`, B: 'Gut' }, ctTeam: 'A' })[0];
assert.ok(evil.includes(`${COLOR.blue}BöseRot${COLOR.white}`));
const [cmd] = sayCommands([`${COLOR.yellow}gelb ${COLOR.blue}blau"; quit`]);
assert.equal(cmd, `say ${COLOR.yellow}gelb ${COLOR.blue}blau quit`, 'nur erlaubte Codes, kein Befehl anhängbar, Farben am Anfang bleiben');
});
test('Mit CSTV-Verzögerung: Mapwechsel nach der Übertragung, Hinweis zum Abschalten am Serienende', () => {
const p = (lines) => lines.map((l) => l.replace(/[\x01-\x10]/g, ''));
const base = { teams, total: 3, autoAdvanceSeconds: 30, tvDelaySeconds: 90, map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } } };
const next = p(mapEndLines({ ...base, decided: null, nextMapName: 'Inferno', series: { A: 1, B: 0 } }));
assert.equal(next.at(-1), 'Nächste Map: Inferno, wird in etwa 90 s geladen (nach der CSTV-Übertragung) - bleibt auf dem Server');
const end = p(mapEndLines({ ...base, decided: 'A', nextMapName: null, series: { A: 2, B: 0 }, shutdownSeconds: 135 }));
assert.equal(end.at(-1), 'Der Server bleibt noch etwa 2 Minuten für die CSTV-Übertragung online und schaltet sich dann ab.');
// Ohne CSTV-Verzögerung kein Hinweis zum Abschalten
const noTv = p(mapEndLines({ ...base, tvDelaySeconds: 0, decided: 'A', nextMapName: null, series: { A: 2, B: 0 } }));
assert.ok(!noTv.some((l) => l.includes('CSTV')));
});
+11 -1
View File
@@ -37,7 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
assert.equal(env.SRCDS_TOKEN, undefined);
assert.equal(env.CS2_GAMEMODE, '2');
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 +sv_deltaticks_enforce 0', 'Rundensicherung für Rollback, keine Trennung wegen Delta-Ticks');
assert.equal(env.CS2_PORT, '27100');
assert.equal(env.TV_PORT, '27200');
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
@@ -97,3 +97,13 @@ test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohn
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
reachable.add(r2.handle.container);
});
test('TV_DELAY geht an das Image, sonst setzt es CSTV nach jedem Mapwechsel auf 0 Sekunden', async () => {
const d = fakeDocker();
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: d.run });
a.isReachable = async () => false;
await a.launch({ matchId: 'tv1', mode: 'competitive', secrets, tvDelaySeconds: 90 });
const run = d.calls.find((c) => c.args?.[0] === 'run' || c[0] === 'run');
const env = envOf(run.args ?? run);
assert.equal(env.TV_DELAY, '90');
});
+224
View File
@@ -0,0 +1,224 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { io as connect } from 'socket.io-client';
import { loadConfig } from '../src/config.js';
import { createApp } from '../src/app.js';
import { DemoServerAdapter } from '../src/servers/demoAdapter.js';
import { applyLogLines, initialLiveState } from '../src/liveScore.js';
import { RconClient } from '../src/servers/rcon.js';
import { createRateLimiter, safeErrorMessage } from '../src/security.js';
// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war.
const quiet = { log() {}, warn() {}, error() {} };
async function start(overrides = {}, adapter) {
const config = loadConfig({
databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '',
demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides,
});
const app = createApp(config, { logger: quiet, adapter });
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const port = app.server.address().port;
const call = async (method, path, { token, body } = {}) => {
const res = await fetch(`http://127.0.0.1:${port}/api${path}`, {
method,
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
};
return { app, port, call, close: () => app.close() };
}
const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } });
test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => {
const { port, call, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) {
socket.emit('stream:subscribe', payload);
socket.emit('match:subscribe', payload);
socket.emit('overview:subscribe', payload);
}
await new Promise((r) => setTimeout(r, 200));
assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch');
} finally {
socket.close();
await close();
}
});
test('Zu viele Socket-Abfragen werden gebremst', async () => {
const { port, close } = await start();
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
try {
await new Promise((resolve) => socket.on('connect', resolve));
let limited = 0;
socket.on('rate_limited', () => { limited += 1; });
for (let i = 0; i < 60; i++) socket.emit('overview:subscribe');
await new Promise((r) => setTimeout(r, 300));
assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen');
} finally {
socket.close();
await close();
}
});
test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => {
class Failing extends DemoServerAdapter {
async start({ secrets }) {
const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`);
err.stderr = '';
throw err;
}
}
const { app, call, close } = await start({}, new Failing({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await app.manager.idle();
const row = app.manager.row(id);
assert.equal(row.status, 'start_failed');
const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body);
for (const secret of [row.rcon_password, row.server_password]) {
assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht');
assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung');
}
assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password));
} finally {
await close();
}
});
test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => {
const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' });
assert.equal(msg, 'Error: port is already allocated');
assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***');
assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch');
});
test('Chat-Nachrichten können keine Map beenden oder starten', () => {
const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
const ts = '11/01/2026 - 20:00:00.000 - ';
const lines = [
`${ts}"Troll<3><[U:1:1]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
`${ts}"Troll<3><[U:1:1]><CT>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`,
`${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]><T>" say "hi"`,
];
const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.deepEqual(events, []);
assert.notEqual(after.status, 'finished');
assert.deepEqual(after.score, { A: 0, B: 0 });
// Die echte Systemzeile wirkt weiterhin
const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
assert.equal(real.events[0]?.type, 'map_end');
});
test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => {
let release;
const stopped = [];
class Slow extends DemoServerAdapter {
async start(args) {
await new Promise((r) => { release = r; });
return super.start(args);
}
async stop(handle) {
stopped.push(handle.matchId);
return super.stop(handle);
}
}
const { app, call, close } = await start({}, new Slow({ startSeconds: 0 }));
try {
const { id } = (await newMatch(call)).body;
await new Promise((r) => setTimeout(r, 50));
await app.manager.stop(id, 'Test');
release();
await app.manager.idle();
assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt');
assert.equal(app.manager.row(id).status, 'stopped');
assert.equal(app.manager.row(id).handle, null);
// Löschen während des Starts
const second = (await newMatch(call)).body.id;
await new Promise((r) => setTimeout(r, 50));
const deleting = app.manager.deleteMatch(second, 'Test');
await deleting;
release();
await app.manager.idle();
assert.ok(stopped.includes(second));
} finally {
await close();
}
});
/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */
async function fakeRcon(onCommand) {
const server = net.createServer((socket) => {
socket.on('data', (buf) => {
const id = buf.readInt32LE(4);
const type = buf.readInt32LE(8);
if (type === 3) {
const ok = Buffer.alloc(14);
ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8);
socket.write(ok);
} else onCommand(socket, id);
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { port: server.address().port, close: () => server.close() };
}
test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => {
const bad = await fakeRcon((socket) => {
const junk = Buffer.alloc(8);
junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum
socket.write(junk);
});
const silent = await fakeRcon(() => {});
try {
const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect();
await assert.rejects(a.command('status'), /geschlossen|keine Antwort/);
a.close();
const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect();
await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/);
b.close();
} finally {
bad.close();
silent.close();
}
});
test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => {
const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' });
try {
const one = (await newMatch(call)).body;
const two = (await newMatch(call)).body;
const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body;
const link = view.organizer.links.overview;
assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link');
const key = new URL(link).searchParams.get('key');
const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body;
const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id);
assert.deepEqual(ids, [one.id], 'nur das eigene Match');
assert.ok(!ids.includes(two.id));
// Gefälschter Schlüssel für ein anderes Match geht nicht
assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403);
// Der globale Schlüssel zeigt weiter alles
const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body;
assert.equal(all.upcoming.length, 2);
} finally {
await close();
}
});
test('Rate-Limiter hält seinen Speicher begrenzt', () => {
const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 });
for (let i = 0; i < 1000; i++) check(`ip-${i}`);
assert.ok(check.size() <= 100);
for (let i = 0; i < 5; i++) assert.equal(check('x'), true);
assert.equal(check('x'), false);
});
+3
View File
@@ -78,6 +78,9 @@ test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht',
};
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('sv_deltaticks_enforce 0'), 'keine Trennung wegen Delta-Ticks');
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.some((c) => /^tv_delay \d+$/.test(c)), 'tv_delay nach dem Laden der Map');
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
assert.ok(!bad.some((c) => c.startsWith('logaddress')));