194 lines
8.9 KiB
JavaScript
194 lines
8.9 KiB
JavaScript
// 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 lan = h.lanPorts ?? this.config.docker.lanPorts ?? [];
|
|
const rangeStart = h.portRangeStart ?? this.config.docker.portRangeStart;
|
|
const rangeEnd = h.portRangeEnd ?? this.config.docker.portRangeEnd;
|
|
if (lan.some((p) => p >= rangeStart && p <= rangeEnd)) {
|
|
throw new HttpError(400, `LAN-Ports von ${h.name} dürfen nicht im normalen Portbereich liegen`, '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;
|
|
}
|
|
}
|