import express from 'express'; import { formatPortList } from './config.js'; import { formatDockerEvent } from './servers/dockerAdapter.js'; import { HttpError } from './matchService.js'; import { createRateLimiter, randomToken, safeEqual } from './security.js'; const SESSION_HOURS = 12; function bearer(req) { const header = req.get('authorization') ?? ''; return header.startsWith('Bearer ') ? header.slice(7).trim() : null; } /** * Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token * (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden * Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle. */ export function createAdminRouter({ service, manager, config, settings, knife, pools }) { const router = express.Router(); const sessions = new Map(); // Token -> Ablaufzeit (ms) const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 }); const wrap = (fn) => async (req, res, next) => { try { const result = await fn(req, res); res.json(result ?? { ok: true }); } catch (err) { next(err); } }; const requireEnabled = (req, res, next) => { if (config.adminPassword) return next(); return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled')); }; const requireAdmin = (req, res, next) => { const token = bearer(req); const expires = token && sessions.get(token); if (expires && expires > Date.now()) return next(); if (token) sessions.delete(token); return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized')); }; // Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams const adminViewer = (matchId, extra = {}) => ({ matchId, role: 'organizer', isOrganizer: true, isCaptain: false, isSpectator: false, playerId: null, team: null, ...extra, }); router.use(requireEnabled); router.post('/login', (req, res, next) => { if (!loginLimit(`admin:${req.ip}`)) { return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited')); } if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) { return next(new HttpError(401, 'Falsches Passwort', 'unauthorized')); } const token = randomToken(); const expiresAt = Date.now() + SESSION_HOURS * 3_600_000; sessions.set(token, expiresAt); return res.json({ token, expiresAt }); }); router.post('/logout', (req, res) => { const token = bearer(req); if (token) sessions.delete(token); res.json({ ok: true }); }); router.get('/session', requireAdmin, (req, res) => res.json({ ok: true })); // Discord-Meldungen: welche Ereignisse gemeldet werden // Gameserver-Einstellungen: Vorgaben aus der .env, vom Admin überschreibbar router.get('/server-settings', requireAdmin, wrap(() => ({ fields: settings.view() }))); router.put('/server-settings', requireAdmin, wrap(async (req) => { const changed = settings.update(req.body?.values ?? {}); if (changed.includes('vetoStepSeconds')) service.startVetoTimer(); const live = changed.length ? await manager.applyLiveSettings(changed) : { updated: 0, failed: [] }; return { fields: settings.view(), changed, live }; })); // Docker-Hosts: Liste wie CS2_DOCKER_HOSTS, gespeichert in der Datenbank const hostView = () => ({ hosts: (manager.adapter.hosts ?? []).map((h) => ({ name: h.name, dockerHost: h.dockerHost, publicHost: h.publicHost, rconHost: h.rconHost, portStart: h.portRangeStart, portEnd: h.portRangeEnd, volume: h.volume, lanPorts: formatPortList(h.lanPorts), })), overridden: settings.hostsOverridden(), editable: !!manager.adapter.setHosts, image: config.docker.image, pull: manager.adapter.pullStatus?.() ?? null, files: manager.adapter.filesStatus?.() ?? null, }); router.get('/docker-hosts', requireAdmin, wrap(() => hostView())); router.put('/docker-hosts', requireAdmin, wrap((req) => { const list = req.body?.hosts; const hosts = settings.parseHosts(list); manager.setDockerHosts(hosts); settings.saveHosts(list, hosts); return hostView(); })); router.delete('/docker-hosts', requireAdmin, wrap(() => { manager.setDockerHosts(settings.defaultHosts); settings.resetHosts(); return hostView(); })); // Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts. // Spieldateien auf allen Hosts per SteamCMD aktualisieren. Hosts mit laufenden Servern werden ausgelassen, // außer mit { force: true }. Läuft im Hintergrund, Stand über GET /docker-hosts. router.post('/docker-hosts/update-files', requireAdmin, wrap((req) => { if (!manager.adapter.updateGameFiles) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported'); if (manager.adapter.filesStatus().running) throw new HttpError(409, 'Die Spieldateien werden bereits aktualisiert', 'busy'); const skip = new Map(); if (!req.body?.force) { const byAddress = new Map(manager.adapter.hosts.map((h) => [h.publicHost, h.name])); const busy = new Map(); for (const s of manager.activeServers()) { const name = s.hostName ?? byAddress.get(s.host); if (name) busy.set(name, (busy.get(name) ?? 0) + 1); } for (const k of knife.rows()) { if (!k.handle) continue; let name = byAddress.get(k.host); try { name = JSON.parse(k.handle).hostName ?? name; } catch { // kaputter Handle: Adresse reicht } if (name) busy.set(name, (busy.get(name) ?? 0) + 1); } for (const [name, n] of busy) skip.set(name, `ausgelassen: ${n} laufende${n === 1 ? 'r' : ''} Server`); } manager.adapter.updateGameFiles({ skip }).catch(() => {}); return hostView(); })); router.post('/docker-hosts/pull', requireAdmin, wrap(() => { if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported'); if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy'); manager.adapter.pullImage().catch(() => {}); return hostView(); })); // Map-Pools: welche Maps gespielt werden. Der Veto-Ablauf passt sich der Anzahl an. const poolView = () => ({ pools: Object.values(pools).map((p) => ({ mode: p.mode, label: p.label, maps: p.allMaps.map((m) => ({ ...m, active: p.maps.some((a) => a.id === m.id) })), formats: p.formats, })), }); router.get('/map-pools', requireAdmin, wrap(() => poolView())); router.put('/map-pools', requireAdmin, wrap((req) => { settings.saveMapSelection(pools, req.body?.mode, req.body?.maps); return poolView(); })); // Messer-Server für die Seitenwahl router.get('/knife', requireAdmin, wrap(() => ({ servers: knife.list(), modes: knife.modes(), maxServers: config.knife.maxServers, }))); router.post('/knife', requireAdmin, wrap((req) => knife.create({ map: req.body?.map, mode: req.body?.mode }))); router.post('/knife/:id/reset', requireAdmin, wrap((req) => knife.reset(req.params.id))); router.post('/knife/:id/round', requireAdmin, wrap((req) => knife.startRound(req.params.id))); router.post('/knife/:id/restart', requireAdmin, wrap((req) => knife.restart(req.params.id))); router.delete('/knife/:id', requireAdmin, wrap((req) => knife.remove(req.params.id))); router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings())); router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events))); router.post('/notifications/test', requireAdmin, wrap(async () => { if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured'); const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] }; if (!(await service.notifier.send(test))) { throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed'); } return { ok: true }; })); // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) // Konsolenausgabe eines Containers live (Match-Server oder Messer-Server). Passwörter werden ausgeblendet. let openStreams = 0; router.get('/logs/:kind/:id', requireAdmin, (req, res, next) => { try { const { kind, id } = req.params; const row = kind === 'match' ? manager.row(id) : kind === 'knife' ? knife.row(id) : null; if (!row) throw new HttpError(404, 'Server nicht gefunden', 'not_found'); if (!row.handle) throw new HttpError(409, 'Für diesen Server gibt es noch keinen Container', 'no_container'); if (!manager.adapter.streamLogs) throw new HttpError(501, 'Dieser Adapter hat keine Konsolenausgabe', 'unsupported'); if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited'); const tail = Math.max(0, Math.min(2000, Number.parseInt(req.query.tail, 10) || 200)); const secrets = [row.rcon_password, row.server_password, row.tv_password].filter((s) => s && s.length >= 4); const mask = (text) => secrets.reduce((t, s) => t.split(s).join('••••'), text); const proc = manager.adapter.streamLogs(JSON.parse(row.handle), { tail }); openStreams += 1; res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' }); // Zeilenweise ausgeben, damit ein Passwort nie an einer Paketgrenze zerrissen und so doch sichtbar wird const pipe = (stream) => { let rest = ''; stream.setEncoding?.('utf8'); stream.on('data', (chunk) => { const lines = (rest + chunk).split('\n'); rest = lines.pop(); if (lines.length) res.write(mask(lines.join('\n')) + '\n'); }); stream.on('end', () => { if (rest) res.write(mask(rest) + '\n'); }); }; pipe(proc.stdout); pipe(proc.stderr); let done = false; const finish = (note) => { if (done) return; done = true; openStreams -= 1; if (note && !res.writableEnded) res.end(`\n[${note}]\n`); }; proc.on('close', () => finish('Ausgabe beendet')); proc.on?.('error', (err) => finish(`Fehler: ${err.message}`)); req.on('close', () => { proc.kill(); finish(null); }); } catch (err) { next(err); } }); // Live-Log eines Docker-Hosts: seine Ereignisse (Container gestartet, beendet, entfernt, Images geladen …). Nur lesen. router.get('/host-log/:name', requireAdmin, (req, res, next) => { try { if (!manager.adapter.streamEvents) throw new HttpError(501, 'Nur mit dem Docker-Adapter möglich', 'unsupported'); if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited'); const since = Math.max(0, Math.min(24 * 60, Number.parseInt(req.query.since, 10) || 120)); const proc = manager.adapter.streamEvents(req.params.name, { sinceMinutes: since }); if (!proc) throw new HttpError(404, 'Docker-Host nicht gefunden', 'not_found'); openStreams += 1; res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' }); res.write(`Ereignisse von ${req.params.name} seit ${since} Minuten, neue erscheinen live.\n`); let rest = ''; proc.stdout.setEncoding('utf8'); proc.stdout.on('data', (chunk) => { const lines = (rest + chunk).split('\n'); rest = lines.pop(); const out = lines.map(formatDockerEvent).filter(Boolean); if (out.length) res.write(out.join('\n') + '\n'); }); proc.stderr.setEncoding('utf8'); proc.stderr.on('data', (chunk) => res.write(`[docker] ${chunk}`)); let done = false; const finish = (note) => { if (done) return; done = true; openStreams -= 1; if (note && !res.writableEnded) res.end(`\n[${note}]\n`); }; proc.on('close', () => finish('Ausgabe beendet')); proc.on('error', (err) => finish(`Fehler: ${err.message}`)); req.on('close', () => { proc.kill(); finish(null); }); } catch (err) { next(err); } }); // Auslastung und Speicher der Docker-Hosts mit den Servern darauf router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() }))); // ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches router.get('/matches', requireAdmin, wrap(async (req) => { const archived = req.query.archived === '1'; const ids = service.db.prepare(archived ? 'SELECT id FROM matches WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 300' : 'SELECT id FROM matches WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 150').all(); const archivedCount = service.db.prepare('SELECT COUNT(*) AS n FROM matches WHERE archived_at IS NOT NULL').get().n; return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), archivedCount, }; })); const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { service.getMatch(req.params.id); return fn(adminViewer(req.params.id), req.body ?? {}, req); })); action('finish', (v) => service.close(v, 'finished')); action('cancel', (v) => service.close(v, 'cancelled')); action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/retry', (v) => manager.retry(v)); action('server/warmup-end', (v) => manager.endWarmup(v)); action('archive', (v) => service.setArchived(v.matchId, true)); action('unarchive', (v) => service.setArchived(v.matchId, false)); action('server/pause', (v) => manager.setPaused(v, true)); action('server/resume', (v) => manager.setPaused(v, false)); action('series/next', (v) => manager.loadNextMap(v)); action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam })); action('veto/action', (v, b) => service.vetoAction( adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }), { stepIndex: b.stepIndex, map: b.map, side: b.side }, )); action('kick', (v, b) => service.kick(v, b.playerId)); action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); // Rollback: nur für Admins. Diese Routen gibt es für Organisatoren nicht. router.get('/matches/:id/rollback-points', requireAdmin, wrap(async (req) => { service.getMatch(req.params.id); return { points: await manager.rollbackPoints(adminViewer(req.params.id)) }; })); action('rollback', (v, b) => manager.rollback(v, b.file)); // Match endgültig löschen (Server wird vorher freigegeben) router.delete('/matches/:id', requireAdmin, wrap(async (req) => { await manager.deleteMatch(req.params.id); return { ok: true }; })); return router; }