Files
game-server-watcher-a-sync/src/server.ts
2024-03-23 14:29:31 +01:00

212 lines
7.8 KiB
TypeScript

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<string, string> = {
'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('<html><head></head><body>404 &#x1F4A2</body></html>');
}
} 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('<html><head></head><body>400 &#x1F4A2</body></html>');
}
} 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(`<html><head></head><body>${code} &#x1F4A2</body></html>`);
}
}).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;
}