Files
Cs2-Mapveto/server/src/realtime.js
T
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

161 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Server } from 'socket.io';
// 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
* REST-API. Der Socket liefert nach jeder Änderung den vollständigen, rollenabhängigen Match-Zustand.
* Dadurch ist ein Client nach Neuladen oder Verbindungsabbruch sofort wieder synchron.
*/
export function attachRealtime(httpServer, { service, config }) {
const io = new Server(httpServer, {
path: '/socket.io',
cors: { origin: config.publicUrl, credentials: false },
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60_000 },
});
// matchId -> Map(socketId -> presenceKey)
const presence = new Map();
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
const sendState = (socket) => {
const { matchId, token, playerToken } = socket.data;
try {
const viewer = service.resolveViewer(matchId, [token, playerToken]);
// Rolle kann sich geändert haben (z. B. beigetreten, entfernt) – Anwesenheit vor dem Snapshot nachziehen
// und die anderen informieren, damit der Online-Status sofort stimmt.
const map = presence.get(matchId);
const key = viewer.playerId ?? `socket:${socket.id}`;
const changed = map && map.get(socket.id) !== key;
map?.set(socket.id, key);
socket.emit('match:state', service.snapshot(matchId, viewer));
if (changed) broadcastMatch(matchId);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code ?? 'error' });
}
};
let overviewTimer = null;
const broadcastOverview = () => {
clearTimeout(overviewTimer);
overviewTimer = setTimeout(() => io.to('overview').emit('overview', { matches: service.listMatches() }), 150);
};
const pending = new Map();
const broadcastMatch = (matchId) => {
// Mehrere Änderungen innerhalb kurzer Zeit werden zu einem Update zusammengefasst.
if (pending.has(matchId)) return;
pending.set(matchId, setTimeout(async () => {
pending.delete(matchId);
const sockets = await io.in(`match:${matchId}`).fetchSockets();
sockets.forEach(sendState);
}, 50));
};
let streamTimer = null;
const broadcastStream = () => {
clearTimeout(streamTimer);
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) => {
broadcastMatch(matchId);
broadcastOverview();
broadcastStream();
});
io.on('connection', (socket) => {
// 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() });
});
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.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 }));
});
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 = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
try {
service.getMatch(matchId);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code });
return;
}
socket.join(`match:${matchId}`);
if (!presence.has(matchId)) presence.set(matchId, new Map());
service.touch(matchId);
sendState(socket);
broadcastMatch(matchId); // Online-Status für alle aktualisieren
});
const leave = (matchId) => {
socket.leave(`match:${matchId}`);
const map = presence.get(matchId);
if (map?.delete(socket.id)) {
if (map.size === 0) presence.delete(matchId);
service.touch(matchId);
broadcastMatch(matchId);
}
};
socket.on('disconnect', () => {
clearInterval(refill);
try {
if (socket.data?.matchId) leave(socket.data.matchId);
} catch (err) {
console.error(`[socket] disconnect: ${err.message}`);
}
});
});
// Beim Herunterfahren: ausstehende Sammel-Updates verwerfen, damit nichts mehr auf die Datenbank zugreift
io.disposeRealtime = () => {
clearTimeout(overviewTimer);
clearTimeout(streamTimer);
for (const t of pending.values()) clearTimeout(t);
pending.clear();
service.removeAllListeners('changed');
};
return io;
}