Compare commits

...
16 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
27 changed files with 897 additions and 90 deletions

No files matched your search

+1 -1
View File
@@ -394,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. - **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht. - **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. - **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 ### Beamer-Diashow
-6
View File
@@ -6,12 +6,6 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<title>CS2 Mapveto – Maxlan e.V.</title> <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="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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
+38 -26
View File
@@ -1,40 +1,52 @@
import CopyField from './CopyField.jsx'; 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 }) { export default function OrganizerPanel({ state, act, busy, matchId, token }) {
const { codes, links } = state.organizer; const { codes, links } = state.organizer;
const open = !['finished', 'cancelled'].includes(state.status); const open = !['finished', 'cancelled'].includes(state.status);
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`; const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
const label = 'Zuschauer (Stream, Kommentatoren)';
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}`);
return ( return (
<section className="panel organizer-panel"> <section className="panel organizer-panel">
<h2>Einladungen</h2> <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> <p className="small muted">Die Einladungslinks der Teams stehen unten in der jeweiligen Team-Karte.</p>
{[ <div className="invite spectator">
['A', state.teams.A.name, 'team-A'], <div className="invite-head">
['B', state.teams.B.name, 'team-B'], <strong>{label}</strong>
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'], <span className="invite-code">{codes.spectator}</span>
].map(([key, label, cls]) => ( {open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, 'spectator', label)}>Neuer Code</button>}
<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>
)}
</div> </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"> <details className="org-link">
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary> <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> <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 }) { export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
const isOwnTeam = viewer.team === side; const isOwnTeam = viewer.team === side;
const inLobby = state.status === 'lobby'; const inLobby = state.status === 'lobby';
@@ -59,6 +60,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
</div> </div>
)} )}
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>} {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> </section>
); );
} }
+7
View File
@@ -2,6 +2,13 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx'; 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'; import './styles.css';
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
+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 step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
const running = (matches ?? []).filter((m) => m.group === 'running'); const running = (matches ?? []).filter((m) => m.group === 'running');
const open = (matches ?? []).filter((m) => m.group === 'open'); 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 ( return (
<div className="overview home"> <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 { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
.knife-connect > :first-child { flex: 1 1 260px; } .knife-connect > :first-child { flex: 1 1 260px; }
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; } .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", "name": "@cs2-mapveto/client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
@@ -725,6 +727,24 @@
"node": ">=12" "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": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", "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 token = randomToken();
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000; 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); sessions.set(token, expiresAt);
return res.json({ token, expiresAt }); return res.json({ token, expiresAt });
}); });
+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= // Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => { router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) { // Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden'); 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(); return service.streamOverview({ matchId: access.matchId });
})); }));
router.post('/matches', limit('create'), wrap((req) => { 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: 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) // 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 = { const RE = {
matchStart: /World triggered "Match_Start" on "([^"]+)"/, matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
warmupStart: /World triggered "Warmup_Start"/, warmupStart: /^World triggered "Warmup_Start"$/,
worldRoundEnd: /World triggered "Round_End"/, worldRoundEnd: /^World triggered "Round_End"$/,
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/, teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/, 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. // 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 // Gründe für ein Rundenende aus den SFUI-Hinweisen
const REASONS = { const REASONS = {
Target_Bombed: 'bomb', 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 }); 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; let m;
if ((m = RE.matchStart.exec(line))) { if ((m = RE.matchStart.exec(line))) {
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu. // Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
+30 -3
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js'; 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 { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js'; import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
import { trimLiveState } from './rounds.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); 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'); if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
this.chatRate.set(who, [...recent, now]); 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; const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
this.db.prepare(` 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 – * Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten. * keine Codes, keine Verbindungsdaten.
*/ */
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) { /** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all(); 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 now = this.now();
const active = []; const active = [];
const upcoming = []; const upcoming = [];
@@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter {
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode // Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
stream: `${base}/stream/${formatCode(match.code_spectator)}`, stream: `${base}/stream/${formatCode(match.code_spectator)}`,
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt) // 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))}` : ''}`,
}, },
}; };
} }
+47 -11
View File
@@ -1,5 +1,8 @@
import { Server } from 'socket.io'; 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 * 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; let streamTimer = null;
const broadcastStream = () => { const broadcastStream = () => {
clearTimeout(streamTimer); 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) => { service.on('changed', (matchId) => {
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
}); });
io.on('connection', (socket) => { 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.join('overview');
socket.emit('overview', { matches: service.listMatches() }); socket.emit('overview', { matches: service.listMatches() });
}); });
socket.on('stream:subscribe', ({ key } = {}) => { on('stream:subscribe', ({ key }) => {
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) { 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' }); socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return; return;
} }
socket.join('stream-overview'); socket.leave('stream-overview');
socket.emit('stream:overview', service.streamOverview()); 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 } = {}) => { on('match:subscribe', ({ matchId, token, playerToken }) => {
if (typeof matchId !== 'string') return; if (typeof matchId !== 'string' || matchId.length > 64) return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6)); for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
const str = (v) => (typeof v === 'string' ? v : null); 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 { try {
service.getMatch(matchId); service.getMatch(matchId);
} catch (err) { } catch (err) {
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
}; };
socket.on('disconnect', () => { 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); 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). */ /** 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(); const hits = new Map();
return function check(key) { let lastSweep = Date.now();
function check(key) {
const now = Date.now(); 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); const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) { 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 }); hits.set(key, { start: now, count: 1 });
return true; return true;
} }
entry.count += 1; 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; 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;
}
+4
View File
@@ -61,6 +61,10 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
'mp_match_can_clinch 1', 'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest // Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0', '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_autoteambalance 0',
'mp_limitteams 0', 'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end) // 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 { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
import { sanitizeConsoleText } from './cs2Config.js'; import { sanitizeConsoleText } from './cs2Config.js';
import { safeErrorMessage } from '../security.js';
const exec = promisify(execFile); const exec = promisify(execFile);
@@ -149,8 +150,16 @@ export class DockerServerAdapter {
async docker(args, timeout = 60_000) { async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args, this.options.name); 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 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 }); try {
return stdout.trim(); 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()) { 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 { port, tvPort } = this.allocatePorts(blocked);
const lan = (this.options.lanPorts ?? []).includes(port); const lan = (this.options.lanPorts ?? []).includes(port);
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) { 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 container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = { const env = {
@@ -257,9 +266,13 @@ export class DockerServerAdapter {
CS2_GAMETYPE: '0', CS2_GAMETYPE: '0',
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1', CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
CS2_SERVER_HIBERNATE: '0', CS2_SERVER_HIBERNATE: '0',
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback) // -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files', // 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', 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_PORT: String(tvPort),
TV_PW: secrets.tvPassword, TV_PW: secrets.tvPassword,
TV_RELAY_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 // 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; if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
await this.docker(['rm', '-f', container]).catch(() => {}); 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 { return {
+13 -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. // 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 { HttpError } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword, safeErrorMessage } from '../security.js';
const SLOW_POLL_MS = 10_000; 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 // Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
@@ -32,6 +32,8 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
'mp_match_can_clinch 1', 'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest // Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0', '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_end_restart 1',
'mp_match_restart_delay 10', 'mp_match_restart_delay 10',
'mp_halftime 0', 'mp_halftime 0',
@@ -207,7 +209,14 @@ export class KnifeServers {
mode: row.mode, mode: row.mode,
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) }, secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`, 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, { this.update(id, {
status: 'starting', status: 'starting',
message: 'Container läuft, Spielserver lädt', message: 'Container läuft, Spielserver lädt',
@@ -219,8 +228,9 @@ export class KnifeServers {
started_at: Date.now(), started_at: Date.now(),
}); });
} catch (err) { } catch (err) {
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` }); const message = safeErrorMessage(err, [row.rcon_password]);
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed'); 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 { } finally {
this.busy.delete(id); this.busy.delete(id);
} }
+16 -5
View File
@@ -75,9 +75,17 @@ export class RconClient {
onData(chunk) { onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]); this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer); let decoded;
this.buffer = rest; try {
for (const packet of packets) for (const listener of this.listeners) listener(packet); 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() { authenticate() {
@@ -105,15 +113,18 @@ export class RconClient {
const id = this.nextId++; const id = this.nextId++;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let body = ''; let body = '';
let answered = false;
let quietTimer = null; 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) => { const listener = (packet) => {
if (packet.id !== id) return; if (packet.id !== id) return;
answered = true;
body += packet.body; body += packet.body;
clearTimeout(quietTimer); clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs); 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) => { const finish = (err) => {
clearTimeout(timer); clearTimeout(timer);
clearTimeout(quietTimer); clearTimeout(quietTimer);
+3 -2
View File
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
try { try {
await runRcon(target, plan.beforeMapChange); await runRcon(target, plan.beforeMapChange);
} catch (err) { } catch (err) {
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler. // Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`); // 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; const deadline = Date.now() + loadTimeoutMs;
+140 -7
View File
@@ -1,9 +1,11 @@
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js'; import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.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 { DemoMatchSimulator } from './demoMatchSimulator.js';
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js'; import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
import { HttpError, CLOSED_STATUSES } from '../matchService.js'; import { HttpError, CLOSED_STATUSES } from '../matchService.js';
import { randomPassword } from '../security.js'; import { randomPassword, safeErrorMessage } from '../security.js';
export const SERVER_STATUS = { export const SERVER_STATUS = {
starting: 'Server startet', starting: 'Server startet',
@@ -21,6 +23,11 @@ const SLOW_POLL_MS = 30_000;
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss // Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
const KNIFE_RETRIES = 3; const KNIFE_RETRIES = 3;
const KNIFE_RETRY_DELAY_MS = 10_000; 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 * Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
@@ -35,6 +42,11 @@ export class ServerManager {
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung) // Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
this.knifeAttempts = new Map(); this.knifeAttempts = new Map();
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS; 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.lastPoll = new Map();
this.timers = []; this.timers = [];
} }
@@ -42,7 +54,7 @@ export class ServerManager {
init() { init() {
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err))); 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('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)); this.service.on('mapEnded', (e) => this.onMapEnded(e));
} }
@@ -52,6 +64,7 @@ export class ServerManager {
*/ */
onMapEnded({ matchId, number, decided, hasNext }) { onMapEnded({ matchId, number, decided, hasNext }) {
const seconds = this.config.autoAdvanceSeconds; const seconds = this.config.autoAdvanceSeconds;
this.announceMapEnd(matchId, number, decided, hasNext);
if (seconds < 0) return; if (seconds < 0) return;
clearTimeout(this.autoAdvance.get(matchId)); clearTimeout(this.autoAdvance.get(matchId));
const finish = Boolean(decided) || !hasNext; const finish = Boolean(decided) || !hasNext;
@@ -155,6 +168,10 @@ export class ServerManager {
this.autoAdvance.clear(); this.autoAdvance.clear();
this.timers.forEach(clearInterval); this.timers.forEach(clearInterval);
this.timers = []; 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) { row(matchId) {
@@ -343,13 +360,23 @@ export class ServerManager {
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet'); this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
const match = this.service.getMatch(matchId); const match = this.service.getMatch(matchId);
const attempt = (previous?.attempts ?? 0) + 1;
try { try {
const result = await this.adapter.start({ const result = await this.adapter.start({
matchId, matchId,
mode: match.mode, mode: match.mode,
secrets, secrets,
teamNames: { A: match.team_a_name, B: match.team_b_name }, 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, { this.update(matchId, {
handle: JSON.stringify(result.handle), handle: JSON.stringify(result.handle),
host: result.host, host: result.host,
@@ -358,8 +385,9 @@ export class ServerManager {
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch', message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
}); });
} catch (err) { } catch (err) {
this.update(matchId, { status: 'start_failed', message: err.message }); const message = safeErrorMessage(err, Object.values(secrets));
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`); 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 { } finally {
this.busy.delete(matchId); this.busy.delete(matchId);
@@ -433,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. */ /** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
wantsKnife(match, row) { wantsKnife(match, row) {
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready'; return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
@@ -536,8 +640,9 @@ export class ServerManager {
try { try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) { } catch (err) {
this.update(matchId, { status: 'config_failed', message: err.message }); const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`); this.update(matchId, { status: 'config_failed', message });
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
return; return;
} }
this.update(matchId, { this.update(matchId, {
@@ -552,6 +657,7 @@ export class ServerManager {
this.service.markCurrentMapLive(matchId); this.service.markCurrentMapLive(matchId);
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live'); 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.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
this.announceMapLoaded(matchId, seriesMap.number);
} finally { } finally {
this.busy.delete(matchId); this.busy.delete(matchId);
} }
@@ -611,6 +717,9 @@ 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.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 // CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
const fields = []; const fields = [];
if (this.config.match.tvEnabled && row.tv_port) { if (this.config.match.tvEnabled && row.tv_port) {
@@ -677,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) { async stop(matchId, reason) {
this.simulator.stop(matchId); this.simulator.stop(matchId);
this.stopWarmupRepeat(matchId);
clearTimeout(this.closeTimers.get(matchId));
this.closeTimers.delete(matchId);
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);
@@ -710,7 +843,7 @@ export class ServerManager {
if (this.busy.has(row.match_id)) continue; if (this.busy.has(row.match_id)) continue;
const match = this.service.getMatch(row.match_id); const match = this.service.getMatch(row.match_id);
if (CLOSED_STATUSES.includes(match.status)) { 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; continue;
} }
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) { if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
+46
View File
@@ -177,7 +177,11 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
return s.status === 'live' && s.currentMapNumber === 2; return s.status === 'live' && s.currentMapNumber === 2;
}, { label: 'Map 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.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' }); await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
const list = (await call('GET', '/matches')).body.matches; const list = (await call('GET', '/matches')).body.matches;
assert.equal(list.find((m) => m.id === id).group, 'closed'); 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' }); 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'); 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); await winMapForAlpha(app.service.seriesMaps(id)[1].map);
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt // 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' }); 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' }); await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
const snap = (await call('GET', `/matches/${id}`, { token: org })).body; const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 }); assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
@@ -831,3 +841,39 @@ test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende
await close(); 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.SRCDS_TOKEN, undefined);
assert.equal(env.CS2_GAMEMODE, '2'); assert.equal(env.CS2_GAMEMODE, '2');
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 +sv_deltaticks_enforce 0', 'Rundensicherung für Rollback, keine Trennung wegen Delta-Ticks');
assert.equal(env.CS2_PORT, '27100'); assert.equal(env.CS2_PORT, '27100');
assert.equal(env.TV_PORT, '27200'); 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'));
@@ -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); assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
reachable.add(r2.handle.container); 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);
});
+2
View File
@@ -79,6 +79,8 @@ 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; 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(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('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')); assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange; const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
assert.ok(!bad.some((c) => c.startsWith('logaddress'))); assert.ok(!bad.some((c) => c.startsWith('logaddress')));