Admin: Gameserver-Einstellungen, Docker-Hosts und Image-Update im Admin-Bereich
This commit is contained in:
1 parent
69d620bb19
commit
c1c9e7a9f9
10 files changed
+710
-12
No files matched your search
@@ -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 () => {
|
||||
|
||||
+5
-1
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user