Ein kurzlebiger Container je Host aktualisiert CS2 im Spieldateien-Volume. Hosts mit laufenden Servern werden ausgelassen (erzwingbar), während des Updates startet dort kein neuer Server. Fertig nur, wenn Steam die App als vollständig installiert meldet.
339 lines
15 KiB
JavaScript
339 lines
15 KiB
JavaScript
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;
|
||
}
|