diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx
index 0d23b93..1b28105 100644
--- a/client/src/pages/AdminPage.jsx
+++ b/client/src/pages/AdminPage.jsx
@@ -100,6 +100,8 @@ export default function AdminPage() {
setTab('settings')}>Einstellungen
+ {tab === 'settings' && token && }
+ {tab === 'settings' && token && }
{tab === 'settings' && token && }
{notice &&
{notice.text}
}
{error && {error}
}
@@ -438,3 +440,249 @@ function HostUsage({ hosts }) {
function gb(bytes) {
return `${Math.round(bytes / 1e9)} GB`;
}
+const SETTING_GROUPS = [
+ { key: 'match', title: 'Match-Regeln' },
+ { key: 'tv', title: 'CSTV' },
+ { key: 'times', title: 'Zeiten' },
+];
+
+const toInput = (field, value) => (field.type === 'commands' ? value.join('; ') : value);
+const shownDefault = (field) => {
+ if (field.type === 'bool') return field.default ? 'an' : 'aus';
+ if (field.type === 'commands') return field.default.length ? field.default.join('; ') : 'keine';
+ return field.default;
+};
+
+/** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */
+function ServerSettingsPanel({ token, setNotice }) {
+ const [fields, setFields] = useState(null);
+ const [values, setValues] = useState({});
+ const [busy, setBusy] = useState(false);
+
+ const apply = (list) => {
+ setFields(list);
+ setValues(Object.fromEntries(list.map((f) => [f.key, toInput(f, f.value)])));
+ };
+
+ useEffect(() => {
+ api('/admin/server-settings', { token })
+ .then((res) => apply(res.fields))
+ .catch((err) => setNotice({ tone: 'error', text: err.message }));
+ }, [token, setNotice]);
+
+ if (!fields) return null;
+ const changedKeys = fields.filter((f) => String(values[f.key]) !== String(toInput(f, f.value))).map((f) => f.key);
+
+ const save = async () => {
+ setBusy(true);
+ try {
+ const body = Object.fromEntries(changedKeys.map((key) => {
+ const field = fields.find((f) => f.key === key);
+ return [key, field.type === 'int' ? Number(values[key]) : values[key]];
+ }));
+ const res = await api('/admin/server-settings', { method: 'PUT', body: { values: body }, token });
+ apply(res.fields);
+ const parts = ['Einstellungen gespeichert'];
+ if (res.live.updated) parts.push(`${res.live.updated} laufende${res.live.updated === 1 ? 'r' : ''} Server aktualisiert`);
+ if (res.live.failed.length) parts.push(`${res.live.failed.length} Server nicht erreichbar`);
+ setNotice({ tone: res.live.failed.length ? 'error' : 'ok', text: parts.join(' · ') });
+ } catch (err) {
+ setNotice({ tone: 'error', text: err.message });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const input = (f) => {
+ if (f.type === 'bool') {
+ return (
+
+ setValues({ ...values, [f.key]: e.target.checked })} />
+ {f.label}
+
+ );
+ }
+ return (
+
+ {f.label}
+ {f.type === 'commands' ? (
+
+ );
+ };
+
+ return (
+
+ Gameserver
+
+ Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
+
+ {SETTING_GROUPS.map((g) => (
+
+
{g.title}
+
+ {fields.filter((f) => f.group === g.key).map((f) => (
+
+ {input(f)}
+
+ {f.live && live }
+ {f.overridden ? `Vorgabe aus .env: ${shownDefault(f)}` : 'Vorgabe aus .env'}
+
+
+ ))}
+
+
+ ))}
+
+ Speichern
+ {changedKeys.length > 0 && (
+ apply(fields)}>Verwerfen
+ )}
+
+
+ );
+}
+
+const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, volume: 'cs2-mapveto-gamefiles' };
+const HOST_COLUMNS = [
+ { key: 'name', label: 'Name', placeholder: 'host1' },
+ { key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' },
+ { key: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' },
+ { key: 'portStart', label: 'Port von', type: 'number' },
+ { key: 'portEnd', label: 'Port bis', type: 'number' },
+ { key: 'volume', label: 'Volume', placeholder: 'cs2-mapveto-gamefiles' },
+];
+
+/** Docker-Hosts verwalten und das CS2-Image auf allen Hosts gleichzeitig aktualisieren. */
+function DockerHostsPanel({ token, setNotice }) {
+ const [data, setData] = useState(null);
+ const [rows, setRows] = useState([]);
+ const [busy, setBusy] = useState(false);
+
+ const apply = useCallback((res) => {
+ setData(res);
+ setRows(res.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
+ }, []);
+
+ const load = useCallback(() => api('/admin/docker-hosts', { token }), [token]);
+
+ useEffect(() => {
+ load().then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
+ }, [load, apply, setNotice]);
+
+ // Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
+ const pulling = !!data?.pull?.running;
+ useEffect(() => {
+ if (!pulling) return undefined;
+ const timer = setInterval(() => {
+ load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
+ }, 3000);
+ return () => clearInterval(timer);
+ }, [pulling, load]);
+
+ if (!data) return null;
+ if (!data.editable) return null;
+
+ const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
+ const dirty = JSON.stringify(rows) !== original;
+ const setCell = (i, key, value) => setRows(rows.map((r, j) => (j === i ? { ...r, [key]: value } : r)));
+
+ const run = async (fn, success) => {
+ setBusy(true);
+ try {
+ const res = await fn();
+ apply(res);
+ if (success) setNotice({ tone: 'ok', text: success });
+ } catch (err) {
+ setNotice({ tone: 'error', text: err.message });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const save = () => run(() => api('/admin/docker-hosts', {
+ method: 'PUT',
+ token,
+ body: {
+ hosts: rows.map((r) => ({
+ name: r.name.trim(),
+ dockerHost: r.dockerHost.trim() || null,
+ publicHost: r.publicHost.trim(),
+ ...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}),
+ portStart: Number(r.portStart),
+ portEnd: Number(r.portEnd),
+ volume: r.volume.trim() || undefined,
+ })),
+ },
+ }), 'Docker-Hosts gespeichert');
+
+ const reset = () => {
+ if (!window.confirm('Hosts aus der .env verwenden? Die hier gespeicherte Liste wird gelöscht.')) return;
+ run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
+ };
+
+ const pull = () => {
+ if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
+ run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
+ };
+
+ return (
+
+ Docker-Hosts
+
+ {data.overridden ? 'Diese Liste ist im Admin-Bereich gespeichert und ersetzt CS2_DOCKER_HOSTS aus der .env.' : 'Aktuell gilt die Liste aus der .env.'}
+ {' '}Hosts mit laufenden Servern lassen sich erst nach dem Stoppen entfernen.
+
+
+ {rows.map((r, i) => (
+
+ {HOST_COLUMNS.map((c) => (
+
+ {c.label}
+ setCell(i, c.key, e.target.value)} />
+
+ ))}
+ setRows(rows.filter((_, j) => j !== i))}>Entfernen
+
+ ))}
+
+
+ setRows([...rows, { ...EMPTY_HOST }])}>Host hinzufügen
+ Hosts speichern
+ {dirty && apply(data)}>Verwerfen }
+ {data.overridden && Liste aus .env verwenden }
+
+
+ Image aktualisieren
+
+ Lädt {data.image} auf allen Hosts gleichzeitig neu. Laufende Server laufen weiter, neue Server starten mit dem neuen Image.
+
+
+
+ {pulling ? 'Wird aktualisiert …' : 'Auf allen Hosts aktualisieren'}
+
+
+ {data.pull?.results?.length > 0 && (
+
+ {data.pull.results.map((r) => (
+
+ {r.name}
+ {r.state === 'running' ? 'lädt …' : r.state === 'ok' ? 'aktuell' : 'fehlgeschlagen'}
+ {r.message && {r.message}}
+
+ ))}
+ {data.pull.finishedAt && (
+ Zuletzt fertig: {new Date(data.pull.finishedAt).toLocaleString('de-DE')}
+ )}
+
+ )}
+
+ );
+}
diff --git a/client/src/styles.css b/client/src/styles.css
index 3ba7b9a..e4b73f0 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -570,3 +570,22 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.host-disk.is-low { color: var(--error); font-weight: 600; }
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
+.admin-settings h3 { margin: 16px 0 8px; font-size: .95rem; }
+.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px 20px; }
+.setting-item { display: flex; flex-direction: column; gap: 4px; }
+.setting-field { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
+.setting-field input, .setting-field textarea { width: 100%; box-sizing: border-box; font: inherit; color: var(--text); background: var(--bg, transparent); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
+.setting-field textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
+.setting-live { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font-size: .7rem; font-weight: 600; color: var(--primary); border: 1px solid var(--primary); }
+.host-edit { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
+.host-edit-row { display: grid; grid-template-columns: 1fr 2fr 1.4fr .8fr .8fr 1.4fr auto; gap: 8px; align-items: end; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
+@media (max-width: 900px) {
+ .host-edit-row { grid-template-columns: 1fr 1fr; }
+ .host-col-dockerHost { grid-column: 1 / -1; }
+}
+.pull-results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
+.pull-results li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
+.pull-results code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
+.pull-running span { color: var(--pending); }
+.pull-ok span { color: var(--ok, #22c55e); }
+.pull-failed span { color: var(--error); }
diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js
index ee3d38b..fb25c56 100644
--- a/server/src/adminRoutes.js
+++ b/server/src/adminRoutes.js
@@ -14,7 +14,7 @@ function bearer(req) {
* (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 }) {
+export function createAdminRouter({ service, manager, config, settings }) {
const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
@@ -77,6 +77,52 @@ export function createAdminRouter({ service, manager, config }) {
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,
+ })),
+ overridden: settings.hostsOverridden(),
+ editable: !!manager.adapter.setHosts,
+ image: config.docker.image,
+ pull: manager.adapter.pullStatus?.() ?? 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.
+ 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();
+ }));
+
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 () => {
diff --git a/server/src/app.js b/server/src/app.js
index 74d0e04..87762f4 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -11,10 +11,14 @@ import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js';
import { createServerAdapter } from './servers/adapter.js';
import { ServerManager } from './servers/serverManager.js';
+import { ServerSettings } from './serverSettings.js';
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
export function createApp(config, { adapter: adapterOverride, logger = console } = {}) {
const db = openDatabase(config.databaseFile);
+ // Vom Admin geänderte Gameserver-Einstellungen auf die Konfiguration legen, bevor der Adapter entsteht
+ const settings = new ServerSettings({ db, config });
+ settings.load();
const pools = loadMapPools(config.mapPoolFile);
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
@@ -35,7 +39,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
res.set('Referrer-Policy', 'no-referrer');
next();
});
- app.use('/api', createApiRouter({ service, manager, config, pools, adapter }));
+ app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings }));
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
const dist = path.join(REPO_ROOT, 'client', 'dist');
diff --git a/server/src/config.js b/server/src/config.js
index ba8b605..c3cbc39 100644
--- a/server/src/config.js
+++ b/server/src/config.js
@@ -22,7 +22,7 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
}
-function parseDockerHosts(raw) {
+export function parseDockerHosts(raw) {
if (!raw) return null;
let list;
try {
diff --git a/server/src/http.js b/server/src/http.js
index 20badc7..c52a963 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -14,7 +14,7 @@ function accessTokens(req) {
return [bearer(req), req.get('x-player-token')?.trim() || null];
}
-export function createApiRouter({ service, manager, config, pools, adapter }) {
+export function createApiRouter({ service, manager, config, pools, adapter, settings }) {
const router = express.Router();
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
});
router.use(express.json({ limit: '32kb' }));
- router.use('/admin', createAdminRouter({ service, manager, config }));
+ router.use('/admin', createAdminRouter({ service, manager, config, settings }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
diff --git a/server/src/serverSettings.js b/server/src/serverSettings.js
new file mode 100644
index 0000000..bf1d6b2
--- /dev/null
+++ b/server/src/serverSettings.js
@@ -0,0 +1,187 @@
+// Gameserver-Einstellungen, die der Admin zur Laufzeit ändern kann. Die Werte aus der .env sind die
+// Vorgabe; Änderungen landen in der Tabelle settings und überschreiben die Vorgabe auch nach einem Neustart.
+
+import { HttpError } from './matchService.js';
+import { parseDockerHosts } from './config.js';
+
+const SETTINGS_KEY = 'server_settings';
+const HOSTS_KEY = 'docker_hosts';
+
+/**
+ * Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
+ * Server-Manager und Docker-Adapter lesen. `live` = wird an laufende Server geschickt.
+ */
+export const SERVER_SETTING_FIELDS = [
+ { key: 'maxRoundsCompetitive', group: 'match', label: 'Runden Competitive (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
+ get: (c) => c.match.maxRoundsCompetitive, set: (c, v) => { c.match.maxRoundsCompetitive = v; } },
+ { key: 'maxRoundsWingman', group: 'match', label: 'Runden Wingman (mp_maxrounds)', type: 'int', min: 2, max: 60, live: true,
+ get: (c) => c.match.maxRoundsWingman, set: (c, v) => { c.match.maxRoundsWingman = v; } },
+ { key: 'overtime', group: 'match', label: 'Verlängerung (mp_overtime_enable)', type: 'bool', live: true,
+ get: (c) => c.match.overtime, set: (c, v) => { c.match.overtime = v; } },
+ { key: 'extraCommands', group: 'match', label: 'Zusätzliche Konsolenbefehle (durch ; getrennt)', type: 'commands', live: true,
+ get: (c) => c.match.extraCommands, set: (c, v) => { c.match.extraCommands = v; } },
+ { key: 'tvEnabled', group: 'tv', label: 'CSTV aktiv (wirkt ab dem nächsten Mapwechsel)', type: 'bool', live: false,
+ get: (c) => c.match.tvEnabled, set: (c, v) => { c.match.tvEnabled = v; } },
+ { key: 'tvDelaySeconds', group: 'tv', label: 'CSTV-Verzögerung in Sekunden (tv_delay)', type: 'int', min: 0, max: 300, live: true,
+ get: (c) => c.match.tvDelaySeconds, set: (c, v) => { c.match.tvDelaySeconds = v; } },
+ { key: 'vetoStepSeconds', group: 'times', label: 'Zeit pro Veto-Schritt in Sekunden (0 = ohne Frist)', type: 'int', min: 0, max: 600, live: false,
+ get: (c) => c.vetoStepSeconds, set: (c, v) => { c.vetoStepSeconds = v; } },
+ { key: 'idleTimeoutMinutes', group: 'times', label: 'Leerlauf bis zum Abschalten in Minuten', type: 'int', min: 1, max: 1440, live: false,
+ get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
+ { key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
+ get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
+ { key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
+ get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
+];
+
+const fieldFor = (key) => SERVER_SETTING_FIELDS.find((f) => f.key === key);
+
+/** Konsolenbefehle aus Text: getrennt durch ; oder Zeilenumbruch. Befehle, die Zugänge ändern oder den Server beenden, sind gesperrt. */
+export function parseCommands(value) {
+ const list = (Array.isArray(value) ? value : String(value ?? '').split(/[;\r\n]+/))
+ .map((c) => String(c).replace(/[\r\n;]/g, ' ').trim())
+ .filter(Boolean);
+ if (list.length > 30) throw new HttpError(400, 'Höchstens 30 zusätzliche Befehle', 'invalid');
+ for (const c of list) {
+ if (c.length > 120) throw new HttpError(400, `Befehl zu lang: ${c.slice(0, 30)} …`, 'invalid');
+ if (/^(rcon_password|sv_password|tv_password|rcon|quit|exit|_restart|logaddress_\w*)\b/i.test(c)) {
+ throw new HttpError(400, `Befehl nicht erlaubt: ${c.split(' ')[0]}`, 'invalid');
+ }
+ }
+ return list;
+}
+
+function parseValue(field, raw) {
+ if (field.type === 'bool') {
+ if (typeof raw !== 'boolean') throw new HttpError(400, `${field.label}: an oder aus erwartet`, 'invalid');
+ return raw;
+ }
+ if (field.type === 'int') {
+ const value = Number(raw);
+ if (raw === '' || raw === null || !Number.isInteger(value) || value < field.min || value > field.max) {
+ throw new HttpError(400, `${field.label}: ganze Zahl von ${field.min} bis ${field.max} erwartet`, 'invalid');
+ }
+ return value;
+ }
+ return parseCommands(raw);
+}
+
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+export class ServerSettings {
+ constructor({ db, config }) {
+ this.db = db;
+ this.config = config;
+ // Vorgaben aus der .env merken, damit der Admin zurück auf die Vorgabe kann
+ this.defaults = Object.fromEntries(SERVER_SETTING_FIELDS.map((f) => [f.key, structuredClone(f.get(config))]));
+ this.defaultHosts = config.docker.hosts;
+ }
+
+ readJson(key) {
+ const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
+ if (!row) return null;
+ try {
+ return JSON.parse(row.value);
+ } catch {
+ return null;
+ }
+ }
+
+ writeJson(key, value) {
+ this.db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
+ .run(key, JSON.stringify(value));
+ }
+
+ /** Beim Start: gespeicherte Werte auf das config-Objekt legen (vor dem Anlegen des Docker-Adapters). */
+ load() {
+ const saved = this.readJson(SETTINGS_KEY) ?? {};
+ for (const field of SERVER_SETTING_FIELDS) {
+ if (!(field.key in saved)) continue;
+ try {
+ field.set(this.config, parseValue(field, saved[field.key]));
+ } catch {
+ // Ungültiger gespeicherter Wert: Vorgabe aus der .env behalten
+ }
+ }
+ const hosts = this.readJson(HOSTS_KEY);
+ if (hosts) {
+ try {
+ this.config.docker.hosts = parseDockerHosts(JSON.stringify(hosts));
+ } catch {
+ // Kaputte Host-Liste ignorieren, die .env gilt
+ }
+ }
+ }
+
+ view() {
+ const saved = this.readJson(SETTINGS_KEY) ?? {};
+ return SERVER_SETTING_FIELDS.map(({ key, group, label, type, min, max, live, get }) => ({
+ key, group, label, type, min, max, live,
+ value: get(this.config),
+ default: this.defaults[key],
+ overridden: key in saved,
+ }));
+ }
+
+ /** Übernimmt geänderte Werte. Liefert die Schlüssel, die sich wirklich geändert haben. */
+ update(input = {}) {
+ const parsed = Object.entries(input).map(([key, raw]) => {
+ const field = fieldFor(key);
+ if (!field) throw new HttpError(400, `Unbekannte Einstellung: ${key}`, 'invalid');
+ return [field, parseValue(field, raw)];
+ });
+ const saved = this.readJson(SETTINGS_KEY) ?? {};
+ const changed = [];
+ for (const [field, value] of parsed) {
+ if (!same(field.get(this.config), value)) changed.push(field.key);
+ field.set(this.config, value);
+ if (same(value, this.defaults[field.key])) delete saved[field.key];
+ else saved[field.key] = value;
+ }
+ this.writeJson(SETTINGS_KEY, saved);
+ return changed;
+ }
+
+ /** Prüft und speichert eine neue Host-Liste (Format wie CS2_DOCKER_HOSTS). */
+ parseHosts(list) {
+ if (!Array.isArray(list)) throw new HttpError(400, 'Host-Liste erwartet', 'invalid');
+ let hosts;
+ try {
+ hosts = parseDockerHosts(JSON.stringify(list));
+ } catch (err) {
+ throw new HttpError(400, err.message, 'invalid');
+ }
+ for (const h of hosts) {
+ if (h.dockerHost && !/^(ssh:\/\/[\w.@-]+(:\d+)?|tcp:\/\/[\w.-]+:\d+)$/.test(h.dockerHost)) {
+ throw new HttpError(400, `Ungültiges Docker-Ziel für ${h.name}: ${h.dockerHost}`, 'invalid');
+ }
+ if (!/^[\w.-]+$/.test(h.name)) throw new HttpError(400, `Ungültiger Name: ${h.name}`, 'invalid');
+ if (!/^[\w.-]+$/.test(h.publicHost) || !/^[\w.-]+$/.test(h.rconHost)) {
+ throw new HttpError(400, `Ungültige Adresse für ${h.name}`, 'invalid');
+ }
+ if (h.volume && !/^[\w.-]+$/.test(h.volume)) throw new HttpError(400, `Ungültiger Volume-Name für ${h.name}`, 'invalid');
+ const start = h.portRangeStart ?? this.config.docker.portRangeStart;
+ const end = h.portRangeEnd ?? this.config.docker.portRangeEnd;
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1024 || end > 65535 || end - start < 1) {
+ throw new HttpError(400, `Ungültiger Portbereich für ${h.name}`, 'invalid');
+ }
+ }
+ return hosts;
+ }
+
+ saveHosts(list, hosts) {
+ this.writeJson(HOSTS_KEY, list);
+ this.config.docker.hosts = hosts;
+ }
+
+ /** Entfernt die gespeicherte Host-Liste; danach gilt wieder die .env. */
+ resetHosts() {
+ this.db.prepare('DELETE FROM settings WHERE key = ?').run(HOSTS_KEY);
+ this.config.docker.hosts = this.defaultHosts;
+ return this.defaultHosts;
+ }
+
+ hostsOverridden() {
+ return this.readJson(HOSTS_KEY) != null;
+ }
+}
diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js
index 98f26d7..f12407f 100644
--- a/server/src/servers/dockerAdapter.js
+++ b/server/src/servers/dockerAdapter.js
@@ -46,7 +46,7 @@ export class DockerServerAdapter {
}
async docker(args, timeout = 60_000) {
- if (this.runDocker) return this.runDocker(args);
+ 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 { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
@@ -220,7 +220,18 @@ export class DockerHostPool {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
- const hosts = options.hosts ?? [{
+ this.runDocker = runDocker;
+ this.options = options;
+ this.diskCache = new Map();
+ this.diskInflight = new Map();
+ this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
+ this.setHosts(options.hosts);
+ }
+
+ /** Baut die Host-Liste neu auf (beim Start und wenn der Admin die Hosts ändert). */
+ setHosts(list) {
+ const options = this.options;
+ const hosts = list ?? [{
name: 'default',
dockerHost: process.env.DOCKER_HOST || null,
publicHost: options.host,
@@ -240,14 +251,64 @@ export class DockerHostPool {
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
volume: h.volume ?? options.volume,
};
- return { ...options, ...host, host: host.publicHost };
+ const merged = { ...options, ...host, host: host.publicHost };
+ // Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
+ Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
+ return merged;
});
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
- getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
- runDocker,
+ getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
+ runDocker: this.runDocker,
})]));
- this.diskCache = new Map();
- this.diskInflight = new Map();
+ this.diskCache.clear();
+ }
+
+ /**
+ * Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
+ * nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
+ */
+ async hostDocker(name, args, timeout) {
+ const host = this.hosts.find((h) => h.name === name);
+ if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
+ const { stdout } = await exec('ssh', [
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
+ ['docker', ...args].map((a) => `'${a}'`).join(' '),
+ ], { timeout, maxBuffer: 4 * 1024 * 1024 });
+ return stdout.trim();
+ }
+ return this.adapters.get(name).docker(args, timeout);
+ }
+
+ /** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
+ async pullImage() {
+ if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
+ if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
+ const hosts = this.hosts.map((h) => h.name);
+ this.pull = {
+ running: true,
+ startedAt: Date.now(),
+ finishedAt: null,
+ image: this.options.image,
+ results: hosts.map((name) => ({ name, state: 'running', message: null })),
+ };
+ await Promise.all(hosts.map(async (name, i) => {
+ const result = this.pull.results[i];
+ try {
+ await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
+ const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
+ .catch(() => '');
+ Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
+ } catch (err) {
+ Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
+ }
+ }));
+ this.pull.running = false;
+ this.pull.finishedAt = Date.now();
+ return this.pull;
+ }
+
+ pullStatus() {
+ return this.pull;
}
/**
diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js
index 0cc11a3..0801aef 100644
--- a/server/src/servers/serverManager.js
+++ b/server/src/servers/serverManager.js
@@ -179,6 +179,78 @@ export class ServerManager {
}));
}
+ /** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
+ activeServers() {
+ return this.db.prepare(`
+ SELECT s.match_id, s.status, s.host, s.handle, m.mode
+ FROM servers s JOIN matches m ON m.id = s.match_id
+ WHERE s.status != 'stopped' AND s.handle IS NOT NULL
+ `).all().map((r) => {
+ let handle = null;
+ try {
+ handle = JSON.parse(r.handle);
+ } catch {
+ // kaputter Handle: Server zählt trotzdem als aktiv
+ }
+ return { ...r, handle, hostName: handle?.hostName ?? null };
+ });
+ }
+
+ /**
+ * Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
+ * und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
+ */
+ setDockerHosts(hosts) {
+ if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
+ const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
+ // Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
+ const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
+ for (const server of this.activeServers()) {
+ const name = server.hostName ?? this.adapter.hosts[0]?.name;
+ const old = before.get(name);
+ const next = nextList.find((h) => h.name === name);
+ if (!next) {
+ throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
+ }
+ if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
+ throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
+ }
+ }
+ this.adapter.setHosts(hosts);
+ }
+
+ /**
+ * Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
+ * aktualisiert wurden und welche nicht erreichbar waren.
+ */
+ async applyLiveSettings(changed) {
+ const keys = new Set(changed);
+ const rows = this.activeServers().filter((r) => r.status === 'configured');
+ if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
+ const m = this.config.match;
+ const result = { updated: 0, failed: [] };
+ for (const row of rows) {
+ const wingman = row.mode === 'wingman';
+ const commands = [];
+ if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
+ commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
+ }
+ if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
+ if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
+ if (keys.has('extraCommands')) commands.push(...m.extraCommands);
+ if (commands.length === 0) continue;
+ try {
+ await this.adapter.sendCommands(row.handle, commands);
+ result.updated += 1;
+ this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
+ } catch (err) {
+ result.failed.push(row.match_id);
+ this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
+ }
+ }
+ return result;
+ }
+
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) {
const used = new Set();
diff --git a/server/test/serverSettings.test.js b/server/test/serverSettings.test.js
new file mode 100644
index 0000000..c7addc4
--- /dev/null
+++ b/server/test/serverSettings.test.js
@@ -0,0 +1,61 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { loadConfig } from '../src/config.js';
+import { openDatabase } from '../src/db.js';
+import { ServerSettings } from '../src/serverSettings.js';
+import { DockerHostPool } from '../src/servers/dockerAdapter.js';
+
+function setup() {
+ const config = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] }, vetoStepSeconds: 30 });
+ const db = openDatabase(':memory:');
+ return { config, db, settings: new ServerSettings({ db, config }) };
+}
+
+test('Geänderte Einstellungen landen in der Konfiguration und überstehen einen Neustart', () => {
+ const { config, db, settings } = setup();
+ const changed = settings.update({ maxRoundsCompetitive: 30, extraCommands: 'mp_freezetime 15; sv_cheats 0', vetoStepSeconds: 30 });
+ assert.deepEqual(changed, ['maxRoundsCompetitive', 'extraCommands']);
+ assert.equal(config.match.maxRoundsCompetitive, 30);
+ assert.deepEqual(config.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
+
+ // Neustart: neue Konfiguration aus der .env, gespeicherte Werte werden wieder darübergelegt
+ const fresh = loadConfig({ match: { maxRoundsCompetitive: 24, extraCommands: [] } });
+ new ServerSettings({ db, config: fresh }).load();
+ assert.equal(fresh.match.maxRoundsCompetitive, 30);
+ assert.deepEqual(fresh.match.extraCommands, ['mp_freezetime 15', 'sv_cheats 0']);
+});
+
+test('Ungültige Werte und gesperrte Befehle werden abgelehnt', () => {
+ const { config, settings } = setup();
+ assert.throws(() => settings.update({ maxRoundsCompetitive: 500 }), /ganze Zahl von 2 bis 60/);
+ assert.throws(() => settings.update({ overtime: 'ja' }), /an oder aus/);
+ assert.throws(() => settings.update({ extraCommands: 'rcon_password neu' }), /nicht erlaubt/);
+ assert.throws(() => settings.update({ unbekannt: 1 }), /Unbekannte Einstellung/);
+ // Nichts davon wurde übernommen
+ assert.equal(config.match.maxRoundsCompetitive, 24);
+});
+
+test('Zurück auf die Vorgabe entfernt die gespeicherte Abweichung', () => {
+ const { settings } = setup();
+ settings.update({ maxRoundsCompetitive: 30 });
+ assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, true);
+ settings.update({ maxRoundsCompetitive: 24 });
+ assert.equal(settings.view().find((f) => f.key === 'maxRoundsCompetitive').overridden, false);
+});
+
+test('Docker-Hosts: Prüfung der Liste und Übernahme in den Pool, Startzeit wirkt sofort', () => {
+ const { config, settings } = setup();
+ assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', dockerHost: 'ssh://x; rm -rf /' }]), /Ungültiges Docker-Ziel/);
+ assert.throws(() => settings.parseHosts([{ name: 'a', publicHost: '1.2.3.4', portStart: 80, portEnd: 90 }]), /Portbereich/);
+
+ const hosts = settings.parseHosts([
+ { name: 'a', publicHost: '10.0.0.1', dockerHost: 'ssh://server@10.0.0.1', portStart: 27100, portEnd: 27139 },
+ { name: 'b', publicHost: '10.0.0.2', dockerHost: 'ssh://b', portStart: 27100, portEnd: 27119 },
+ ]);
+ const pool = new DockerHostPool({ ...config.docker, startTimeoutMinutes: 20, hosts: null });
+ pool.setHosts(hosts);
+ assert.deepEqual(pool.hostStats().map((h) => [h.name, h.total]), [['a', 20], ['b', 10]]);
+
+ pool.options.startTimeoutMinutes = 45;
+ assert.equal(pool.adapters.get('b').options.startTimeoutMinutes, 45);
+});