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), // Messer-Server für die Seitenwahl (im Admin-Bereich startbar) knife: { map: str('KNIFE_MAP', 'de_mirage'), // Zeit zum Verbinden, bevor die Messerrunde beginnt warmupSeconds: int('KNIFE_WARMUP_SECONDS', 90), maxServers: int('KNIFE_MAX_SERVERS', 4), }, // Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3), 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; }