189 lines
7.9 KiB
JavaScript
189 lines
7.9 KiB
JavaScript
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;
|
||
}
|