import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { createServer } from 'node:http'; import { URL } from 'node:url'; import { games, protocols } from 'gamedig'; import 'dotenv/config'; import { GameServerConfig, readConfig, updateConfig, Watcher } from './watcher.js'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const gamedigPjson = fs.readFileSync(path.resolve(__dirname, '../node_modules/gamedig/package.json'), 'utf-8'); const gamedigVersion = JSON.parse(gamedigPjson).version || '0'; const gswPjson = fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'); const gswVersion = JSON.parse(gswPjson).version || '0'; const HOST = process.env.HOST || '0.0.0.0'; const PORT = parseInt(process.env.PORT || '8080', 10); const SECRET = process.env.SECRET || 'secret'; const DBG = Boolean(Number(process.env.DBG)); interface ApiResponse extends FeaturesResponse, ConfigResponse { message?: string; error?: string; } interface FeaturesResponse{ versions?: { gsw: string; gamedig: string; } services?: { steam: boolean; discord: boolean; telegram: boolean; slack: boolean; }; debug?: boolean; } interface ConfigResponse{ config?: GameServerConfig[]; } interface SelectOptionsResponse { options: { enum_titles: string[]; }; enum: string[]; } const EXT_MIME: Record = { 'html': 'text/html', 'css': 'text/css', 'js': 'application/javascript', 'json': 'application/json', 'png': 'image/png' }; // Start Watcher service const watcher = new Watcher(); watcher.start(); // Start Control Panel service createServer(async (req, res) => { if (DBG) console.log('DBG: %j %j', (new Date()), req.url); try { const reqUrl = new URL(req.url || '', 'http://localhost'); const p = req.url === '/' ? 'index.html' : reqUrl.pathname.slice(1); const ext = path.extname(p).slice(1); if (ext in EXT_MIME && !p.includes('/') && !p.includes('\\')) { if (SECRET !== '') { const filePath = path.resolve('./public/', p); if (fs.existsSync(filePath)) { res.writeHead(200, { 'Content-Type': EXT_MIME[ext] || 'plain/text' }); fs.createReadStream(filePath).on('error', (err: any) => { console.error(err?.message || err); res.end(); }).pipe(res); } else { res.writeHead(404, { 'Content-Type': 'text/html' }); res.end('404 💢'); } } else { res.end('Configure the `SECRET` env var to enable the web UI!'); } } else if (p === 'ping') { if (DBG) console.log('ping'); res.end('pong'); } else if (p === 'gamedig-games') { const gdProtocols = Object.keys(protocols).map(p => `protocol-${p}`); const gdGameTypes = []; const gdGamesNames = []; for (const [type, g] of Object.entries(games)) { gdGameTypes.push(type); gdGamesNames.push(g.name + ' (' + g.release_year + ')'); } res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=0' }); res.end(JSON.stringify({ enum: [...gdGameTypes, ...gdProtocols], options: { enum_titles: [...gdGamesNames, ...gdProtocols] } } as SelectOptionsResponse, null, DBG ? 2 : 0)); } else if (SECRET !== '' && req.headers['x-btoken']) { let status = 200; let re: ApiResponse = {}; if (validateBearerToken(String(req.headers['x-btoken']))) { const reqPath = p.split('/'); try { if (reqPath[0] === 'features') { if (DBG) re.debug = true; re.versions = { gsw: String(gswVersion), gamedig: String(gamedigVersion) }; re.services = { steam: Boolean(process.env.STEAM_WEB_API_KEY), discord: Boolean(process.env.DISCORD_BOT_TOKEN), telegram: Boolean(process.env.TELEGRAM_BOT_TOKEN), slack: Boolean(process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) }; } else if (reqPath[0] === 'config') { if (req.method === 'GET') { re.config = await readConfig(); } else if (req.method === 'POST') { const body = await new Promise(resolve => { let body = ''; req.on('data', (chunk: string) => { body += chunk; }); req.on('end', () => { resolve(body); }); }); // TODO: validate (ajv) await updateConfig(JSON.parse(String(body)) || [] as GameServerConfig[]); await watcher.restart(); re.message = 'Configuration updated. Watcher restarted.'; } else { status = 400; re.error = 'Invalid Request'; } } else if (reqPath[0] === 'flush' && ['servers', 'discord', 'telegram', 'slack'].includes(reqPath[1])) { //TODO: check for and append host:port if available await watcher.restart(reqPath[1]); re.message = '🗑️ ' + reqPath[1].slice(0, 1).toUpperCase() + reqPath[1].slice(1) + ' data flushed.'; } else { status = 400; re.error = 'Invalid Request'; } } catch (err: any) { status = 500; re.error = err.message || String(err); } } else { status = 401; re.error = 'Unauthorized'; } res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=0' }); res.end(JSON.stringify(re, null, DBG ? 2 : 0)); } else { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('400 💢'); } } catch (err: any) { if (DBG) console.error(err?.message || err); const code = err.message === 'Invalid URL' ? 400 : 500; res.writeHead(code, { 'Content-Type': 'text/html' }); res.end(`${code} 💢`); } }).listen(PORT, HOST, () => { console.log('GSW Control Panel service started %s:%s', HOST, PORT); }); function validateBearerToken(btoken: string) { const salt = btoken.slice(0, btoken.length - 141); const valid = btoken.slice(-141, -128); const hash = btoken.slice(-128); if (DBG) console.log('validateBT', valid, salt); if (salt.length > 24 && /^\d{13}$/.test(valid) && /^[a-f0-9]{128}$/.test(hash) && Date.now() < Number(valid)) { return hash === crypto.createHash('sha512').update(salt + valid + SECRET).digest('hex'); } return false; }