Files
Cs2-Mapveto/server/src/config.js
T

189 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const here = path.dirname(fileURLToPath(import.meta.url));
export const SERVER_ROOT = path.resolve(here, '..');
export const REPO_ROOT = path.resolve(SERVER_ROOT, '..');
dotenv.config({ path: path.join(REPO_ROOT, '.env') });
function int(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein (ist: "${raw}")`);
return value;
}
function bool(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
}
/** Portliste aus Text wie "27015-27020" oder "27016,27018-27019". Leer = keine Ports. */
export function parsePortList(value, label = 'LAN-Ports') {
if (Array.isArray(value)) value = value.join(',');
const ports = [];
for (const part of String(value ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
if (!m) throw new Error(`${label}: "${part}" ist kein Port oder Bereich`);
const from = Number(m[1]);
const to = Number(m[2] ?? m[1]);
if (from < 1024 || to > 65535 || to < from || to - from > 100) throw new Error(`${label}: Bereich ${part} ist ungültig`);
for (let p = from; p <= to; p += 1) if (!ports.includes(p)) ports.push(p);
}
return ports;
}
/** Portliste kompakt als Text, z. B. [27015, 27016, 27017] -> "27015-27017". */
export function formatPortList(ports = []) {
const sorted = [...ports].sort((a, b) => a - b);
const parts = [];
for (let i = 0; i < sorted.length; i += 1) {
let j = i;
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j += 1;
parts.push(i === j ? String(sorted[i]) : `${sorted[i]}-${sorted[j]}`);
i = j;
}
return parts.join(',');
}
export function parseDockerHosts(raw) {
if (!raw) return null;
let list;
try {
list = JSON.parse(raw);
} catch {
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
}
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
const names = new Set();
return list.map((h) => {
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
names.add(h.name);
return {
name: String(h.name),
dockerHost: h.dockerHost ?? null,
publicHost: String(h.publicHost),
rconHost: h.rconHost ?? h.publicHost,
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
volume: h.volume ?? undefined,
lanPorts: h.lanPorts === undefined ? undefined : parsePortList(h.lanPorts, `LAN-Ports von ${h.name}`),
};
});
}
function str(name, fallback = '') {
const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw;
}
export function loadConfig(overrides = {}) {
const config = {
port: int('PORT', 3000),
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
logDebug: bool('LOG_DEBUG', false),
// Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30),
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
discordWebhookUrl: str('DISCORD_WEBHOOK_URL'),
// Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus.
adminPassword: str('ADMIN_PASSWORD'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
trustProxy: bool('TRUST_PROXY', false),
serverAdapter: str('SERVER_ADAPTER', 'demo'),
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
match: {
tvDelaySeconds: int('CS2_TV_DELAY', 90),
tvEnabled: bool('CS2_TV_ENABLE', true),
maxRoundsCompetitive: int('CS2_MAXROUNDS_COMPETITIVE', 24),
maxRoundsWingman: int('CS2_MAXROUNDS_WINGMAN', 16),
overtime: bool('CS2_OVERTIME', true),
extraCommands: str('CS2_EXTRA_COMMANDS')
.split(';')
.map((c) => c.trim())
.filter(Boolean),
},
demo: {
startSeconds: int('DEMO_START_SECONDS', 20),
failFirstConfigure: bool('DEMO_FAIL_FIRST_CONFIGURE', false),
// Sekunden pro simulierter Runde nach "Aufwärmphase beenden"
roundSeconds: int('DEMO_ROUND_SECONDS', 4),
},
docker: {
bin: str('DOCKER_BIN', 'docker'),
image: str('CS2_DOCKER_IMAGE', 'joedwards32/cs2:latest'),
host: str('CS2_PUBLIC_HOST'),
rconHost: str('CS2_RCON_HOST', '127.0.0.1'),
portRangeStart: int('CS2_PORT_RANGE_START', 27015),
portRangeEnd: int('CS2_PORT_RANGE_END', 27115),
gslt: str('CS2_GSLT'),
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
// Spielports, die der Steam-Serverbrowser im LAN-Reiter absucht (Steam fragt nur 27015–27020 ab).
// Diese Ports werden zuerst vergeben, CSTV kommt dann aus dem normalen Portbereich. Leer = aus.
lanPorts: parsePortList(str('CS2_LAN_PORTS', '27015-27020'), 'CS2_LAN_PORTS'),
// Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115
tvPortOffset: int('CS2_TV_PORT_OFFSET', 100),
// Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'MaxLAN'),
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
},
staticPool: {
// Format: host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
servers: str('CS2_STATIC_SERVERS')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(parseStaticServer),
},
};
return deepMerge(config, overrides);
}
function parseStaticServer(entry) {
const parts = entry.split(':');
if (parts.length < 3) {
throw new Error(`CS2_STATIC_SERVERS-Eintrag "${entry}" hat nicht das Format host:port:rconPasswort[:tvPort]`);
}
const [host, port, ...rest] = parts;
let tvPort = null;
// Das Passwort darf Doppelpunkte enthalten; ein abschließender rein numerischer Teil ist der TV-Port.
if (rest.length > 1 && /^\d+$/.test(rest[rest.length - 1])) tvPort = Number(rest.pop());
return { host, port: Number(port), rconPassword: rest.join(':'), tvPort: tvPort ?? Number(port) + 5 };
}
function deepMerge(target, source) {
for (const [key, value] of Object.entries(source)) {
if (value && typeof value === 'object' && !Array.isArray(value) && typeof target[key] === 'object') {
deepMerge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}