Admin: Live-Konsole der Container und Live-Log der Docker-Hosts (nur lesen, Passwörter ausgeblendet)
This commit is contained in:
1 parent
fb96fb5d08
commit
ee9c488a7e
9 files changed
+328
-3
No files matched your search
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -164,6 +165,93 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
||||
}));
|
||||
|
||||
// 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() })));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -51,6 +52,20 @@ export class DemoServerAdapter {
|
||||
|
||||
async loadRoundBackup() {}
|
||||
|
||||
/** Simulierte Konsolenausgabe, damit die Konsolen-Ansicht ohne echten Server testbar ist. */
|
||||
streamLogs(handle, { intervalMs = 1000 } = {}) {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
let n = 0;
|
||||
stdout.write('Demo-Adapter: kein echter Container, die Ausgabe ist simuliert\n');
|
||||
const timer = setInterval(() => {
|
||||
n += 1;
|
||||
stdout.write(`[Demo] Server läuft (${handle.map ?? 'de_demo'}), Zeile ${n}\n`);
|
||||
}, intervalMs);
|
||||
const proc = { stdout, stderr, kill() { clearInterval(timer); stdout.end(); stderr.end(); proc.onExit?.(0); }, on(evt, fn) { if (evt === 'close') proc.onExit = fn; return proc; } };
|
||||
return proc;
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
@@ -33,6 +33,39 @@ export function countSlots(host, used) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ereignisse des Docker-Hosts, auf Deutsch. exec_* (Healthchecks fremder Container) und Mount-Rauschen fallen weg,
|
||||
// weil sie nichts aussagen und Befehlszeilen mit Zugangsdaten enthalten können.
|
||||
const EVENT_LABELS = {
|
||||
container: {
|
||||
create: 'angelegt', start: 'gestartet', restart: 'neu gestartet', stop: 'gestoppt', kill: 'abgebrochen',
|
||||
die: 'beendet', destroy: 'entfernt', pause: 'angehalten', unpause: 'fortgesetzt', oom: 'kein Speicher mehr',
|
||||
'health_status: healthy': 'gesund', 'health_status: unhealthy': 'ungesund',
|
||||
},
|
||||
image: { pull: 'geladen', delete: 'gelöscht', untag: 'Markierung entfernt', tag: 'markiert' },
|
||||
volume: { create: 'angelegt', destroy: 'gelöscht' },
|
||||
};
|
||||
const EVENT_TYPES = { container: 'Container', image: 'Image', volume: 'Volume' };
|
||||
|
||||
/** Eine JSON-Zeile von `docker events` lesbar machen. null = Ereignis wird nicht angezeigt. */
|
||||
export function formatDockerEvent(line) {
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const label = EVENT_LABELS[e.Type]?.[e.Action];
|
||||
if (!label) return null;
|
||||
const attrs = e.Actor?.Attributes ?? {};
|
||||
const time = new Date((e.time ?? 0) * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const name = attrs.name ?? e.Actor?.ID?.slice(0, 12) ?? '';
|
||||
const app = attrs['cs2-mapveto'] === '1' ? ' [Mapveto]' : '';
|
||||
const extra = e.Action === 'die' && attrs.exitCode != null ? ` (Exit-Code ${attrs.exitCode})`
|
||||
: e.Action === 'kill' && attrs.signal ? ` (Signal ${attrs.signal})`
|
||||
: e.Type === 'container' && attrs.image ? ` (${attrs.image})` : '';
|
||||
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
|
||||
}
|
||||
|
||||
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
||||
const PORT_BUSY = /port is already allocated|address already in use/i;
|
||||
const DISK_CACHE_MS = 60_000;
|
||||
@@ -278,6 +311,18 @@ export class DockerServerAdapter {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
|
||||
/** Ereignisse des Docker-Hosts live (`docker events`), ab `sinceMinutes` in der Vergangenheit. Nur lesen. */
|
||||
streamEvents({ sinceMinutes = 120 } = {}) {
|
||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||
return spawn(this.options.bin, ['events', '--since', `${Math.max(0, sinceMinutes)}m`, '--format', '{{json .}}'], { env, windowsHide: true });
|
||||
}
|
||||
|
||||
/** Konsolenausgabe des Containers live: `docker logs --follow`. Liefert den Kindprozess (stdout/stderr, kill). */
|
||||
streamLogs(handle, { tail = 200 } = {}) {
|
||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||
return spawn(this.options.bin, ['logs', '--follow', '--tail', String(tail), handle.container], { env, windowsHide: true });
|
||||
}
|
||||
|
||||
async stop(handle) {
|
||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||
}
|
||||
@@ -474,6 +519,15 @@ export class DockerHostPool {
|
||||
return this.adapterFor(handle).sendCommands(handle, commands);
|
||||
}
|
||||
|
||||
streamLogs(handle, options) {
|
||||
return this.adapterFor(handle).streamLogs(handle, options);
|
||||
}
|
||||
|
||||
/** Ereignisse eines Hosts nach Namen; null, wenn es den Host nicht gibt. */
|
||||
streamEvents(name, options) {
|
||||
return this.adapters.get(name)?.streamEvents(options) ?? null;
|
||||
}
|
||||
|
||||
async stop(handle) {
|
||||
return this.adapterFor(handle).stop(handle);
|
||||
}
|
||||
|
||||
@@ -690,3 +690,26 @@ test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eig
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||
try {
|
||||
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||
const m = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' } })).body;
|
||||
await waitFor(() => app.service.serverRow(m.id)?.handle, { label: 'Server gestartet' });
|
||||
const base = `http://127.0.0.1:${app.server.address().port}/api/admin/logs`;
|
||||
|
||||
assert.equal((await fetch(`${base}/match/${m.id}`)).status, 401);
|
||||
assert.equal((await fetch(`${base}/match/gibtsnicht`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||
assert.equal((await fetch(`${base}/sonstwas/${m.id}`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||
|
||||
const ctl = new AbortController();
|
||||
const res = await fetch(`${base}/match/${m.id}`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
|
||||
assert.equal(res.status, 200);
|
||||
const reader = res.body.getReader();
|
||||
const { value } = await reader.read();
|
||||
assert.match(new TextDecoder().decode(value), /Demo-Adapter/);
|
||||
ctl.abort();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatDockerEvent } from '../src/servers/dockerAdapter.js';
|
||||
|
||||
const ev = (o) => JSON.stringify({ time: 1793549403, ...o });
|
||||
|
||||
test('Docker-Ereignisse werden lesbar, App-Container markiert', () => {
|
||||
const start = formatDockerEvent(ev({ Type: 'container', Action: 'start', Actor: { ID: 'abc', Attributes: { name: 'cs2mv-knife1', image: 'joedwards32/cs2:latest', 'cs2-mapveto': '1' } } }));
|
||||
assert.match(start, /Container cs2mv-knife1 \[Mapveto\] gestartet \(joedwards32\/cs2:latest\)$/);
|
||||
const die = formatDockerEvent(ev({ Type: 'container', Action: 'die', Actor: { ID: 'abc', Attributes: { name: 'gitea', exitCode: '137' } } }));
|
||||
assert.match(die, /Container gitea beendet \(Exit-Code 137\)$/);
|
||||
assert.match(formatDockerEvent(ev({ Type: 'image', Action: 'pull', Actor: { ID: 'joedwards32/cs2:latest', Attributes: { name: 'joedwards32/cs2' } } })), /Image joedwards32\/cs2 geladen/);
|
||||
});
|
||||
|
||||
test('Healthchecks, Mounts und kaputte Zeilen fallen weg', () => {
|
||||
assert.equal(formatDockerEvent(ev({ Type: 'container', Action: 'exec_start: /bin/sh -c mysql --password=geheim', Actor: { Attributes: {} } })), null);
|
||||
assert.equal(formatDockerEvent(ev({ Type: 'volume', Action: 'mount', Actor: { Attributes: {} } })), null);
|
||||
assert.equal(formatDockerEvent('kein json'), null);
|
||||
});
|
||||
Reference in new issue
Block a user