/* Some foreword: This code is a mess, I know, but it works. Eventually I'll clean it up, probably in some big rewrite. That in itself is pretty ironic, considering this could be used for some sort of server browser, where reliability is key, but I digress. I'll try to comment it as best as I can, but I'm not the best at explaining things. - Chris Chrome */ const Steam = require("steam-server-query") const express = require('express'); const rateLimit = require('express-rate-limit'); const colors = require("colors"); const semver = require("semver"); const childProcess = require('child_process'); const app = express(); const port = 3004; const config = require("./config.json"); // Define objects (Need to finish moving objects up here) var masterList = { lastUpdated: new Date(), servers: [] }; var serverList = { serviceStarted: new Date(), serverCount: 0, highestVersion: "v0.0.0", lowestVersion: "v999.999.999", outdatedServers: 0, versions: {}, erroredCount: 0, lastUpdated: new Date(), servers: {}, errored: {}, offline: {} } BigInt.prototype.toJSON = function () { return this.toString() } var removeDuplicates = function (nums) { let length = nums.length; for (let i = length - 1; i >= 0; i--) { for (let j = i - 1; j >= 0; j--) { if (nums[i] == nums[j]) { nums.splice(j, 1); } } } return nums; }; servers = []; // Keyword split to version, dlcs, tps function splitKeyword(keyword) { data = keyword.split("-") switch (data[1]) { case "0": dlcString = "None" break; case "1": dlcString = "Weapons" break; case "2": dlcString = "Arid" break; case "3": dlcString = "Both" break; default: break; } if (data[0] >= "v1.3.0") { return { "version": data[0], dlcString, dlc: data[1], "tps": data[2] } } else { // For older versions console.log(`${colors.magenta(`[DEBUG ${new Date()}]`)} Absolutely ancient server found, ${data}`); return { "version": data[0], "tps": data[1] } } }; // Do not use this function, it's broken for some reason function countdown(seconds, start, end) { return new Promise((resolve, reject) => { var i = seconds; var interval = setInterval(() => { process.stdout.clearLine(); process.stdout.cursorTo(0); // send newline on first iteration if (i == seconds) { process.stdout.write(`${start}${i}${end}\n`); } else if (i < 0) { process.stdout.write(`${start}${i}${end}\n`); clearInterval(interval); resolve(); } else { process.stdout.write(`${start}${i}${end}`); } i--; }, 1000); }); } function getGitCommitDetails() { try { // Use child_process.execSync to run the `git log -1 --format=%H%x09%an%x09%ae%x09%ad%x09%s` command // and return the output as a string const stdout = childProcess.execSync('git log -1 --format=%H%x09%an%x09%ae%x09%ad%x09%s').toString(); const origin = childProcess.execSync('git config --get remote.origin.url').toString().trim().replace(/\.git$/, ''); // Split the output string into an array of fields const fields = stdout.split('\t'); // Return the commit details as a JSON object return { commit: { hash: fields[0].substring(0, 7), fullHash: fields[0], author: fields[1], email: fields[2], timestamp: fields[3], subject: fields[4] }, origin } } catch (error) { console.error(error); } } function objectLength(object) { var length = 0; for (var key in object) { if (object.hasOwnProperty(key)) { ++length; } } return length; }; // checkServer function function checkServer(address) { Steam.queryGameServerInfo(address).then(data => { data.keywords.split("-") data.address = address.split(":"); data.serverInfo = splitKeyword(data.keywords); output = { "name": data.name, "address": data.address[0], "port": data.address[1], "password": data.visibility == 1 ? true : false, "version": data.serverInfo.version, "outdated": data.serverInfo.version < serverList.highestVersion ? true : false, "dlc": data.serverInfo.dlc, "dlcString": data.serverInfo.dlcString, "tps": data.serverInfo.tps, "players": data.bots, "maxPlayers": data.maxPlayers, "map": data.map, "gameId": data.gameId, "lastUpdated": new Date() } // Check if server is in errored list or offline list, if so, remove it if (serverList.errored[address]) { delete serverList.errored[address]; } if (serverList.offline[address]) { delete serverList.offline[address]; } // Add server to server list serverList.servers[address] = output; return output; }).catch((err) => { output = { "error": "Could not connect to server", "name": "Unknown", "address": address.split(":")[0], "port": address.split(":")[1], "version": "Unknown", "dlc": null, "dlcString": "Unknown", "tps": 0, "players": 0, "maxPlayers": 0, "map": "Unknown", "gameId": "573090" } serverList.errored[address] = output; return output; }); } var highestVersion = "v0.0.0"; // findHighestVersion function function findHighestVersion() { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Finding highest version...`); for (const key in serverList.servers) { if (serverList.servers.hasOwnProperty(key)) { const currentVersion = serverList.servers[key].version; if (semver.valid(currentVersion)) { // check if currentVersion is a valid semver string if (semver.gt(currentVersion, highestVersion)) { highestVersion = currentVersion; } } } } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Highest version is ${highestVersion}`); return highestVersion; } var lowestVersion = 'v999.999.999'; // findLowestVersion function function findLowestVersion() { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Finding lowest version...`); for (const key in serverList.servers) { if (serverList.servers.hasOwnProperty(key)) { const currentVersion = serverList.servers[key].version; if (semver.valid(currentVersion)) { // check if currentVersion is a valid semver string if (semver.lt(currentVersion, lowestVersion)) { lowestVersion = currentVersion; } } } } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Lowest version is ${lowestVersion}`); return lowestVersion; } var outdatedServers = 0; // countOutdatedServers function, counts servers that are outdated function countOutdatedServers() { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Counting outdated servers, latest version is ${highestVersion}`); outdatedServers = 0; for (var key in serverList.servers) { if (serverList.servers.hasOwnProperty(key)) { if (serverList.servers[key].version != highestVersion) { outdatedServers++; } } } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${outdatedServers} servers are outdated!`); return outdatedServers; }; var versions = {}; // Track server versions function countVersions() { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Counting server versions...`); const versions = {}; for (const key in serverList.servers) { const server = serverList.servers[key]; versions[server.version] = (versions[server.version] || 0) + 1; } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${Object.keys(versions).length} versions found!`); return versions; } // updateMasterList function function updateMasterList() { // Get master list console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Getting master list...`); Steam.queryMasterServer('hl2master.steampowered.com:27011', Steam.REGIONS.ALL, { appid: 573090, game: "Stormworks", }, 1000, 400).then(servers => { servers = removeDuplicates(servers); console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Got master list!`); masterList.servers = servers; masterList.lastUpdated = new Date(); updateServerList(); }).catch((err) => { console.log(`${colors.red(`[ERROR ${new Date()}]`)} Error updating master list: ${err}`); }); } // updateServerList function function updateServerList() { // Get every server in master list console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Getting server list...`); for (let i = 0; i < masterList.servers.length; i++) { // Get server info checkServer(masterList.servers[i]); serverList.lastUpdated = new Date(); } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Got server list!`); setTimeout(() => { purgeDeadServers(); serverList.serverCount = objectLength(serverList.servers); serverList.highestVersion = findHighestVersion(); serverList.lowestVersion = findLowestVersion(); serverList.outdatedServers = countOutdatedServers(); serverList.versions = countVersions(); serverList.erroredCount = objectLength(serverList.errored); }, 1500); }; // purgeDeadServers function, moves dead servers to offline list function purgeDeadServers() { let counter = 0; console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Purging dead servers...`); for (var key in serverList.servers) { if (serverList.servers.hasOwnProperty(key)) { if (serverList.servers[key].lastUpdated < new Date(new Date().getTime() - 60000)) { serverList.offline[key] = serverList.servers[key]; delete serverList.servers[key]; console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Server ${key} is offline!`); // If server somehow got into errored list, remove it if (serverList.errored[key]) { delete serverList.errored[key]; } counter++; } } } console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Purged ${counter} dead servers!`); } // Startup messages console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Starting Stormworks Server List...`); console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Config: ${JSON.stringify(config)}`); console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Commit: ${getGitCommitDetails().hash}`); // Update master list every 1 minute setInterval(() => { updateMasterList(); }, config.updateInterval * 1000); updateMasterList(); setTimeout(() => { updateServerList(); // Hacky fix for outdated server check }, 5000); if (config.rateLimiterEnabled) { const rateLimiterWarnings = new Set(); app.use(rateLimit({ windowMs: config.rateLimitWindow * 60 * 1000, // X minutes max: config.rateLimitMax, // limit each IP to X requests per windowMs. keyGenerator: function (req) { return config.behindProxy ? req.headers['x-real-ip'] : req.ip; }, skipFailedRequests: true, handler: function (req, res /*, next*/ ) { const ip = config.behindProxy ? req.headers['x-real-ip'] : req.ip; const remainingTime = Math.round((req.rateLimit.resetTime - Date.now()) / 1000); res.status(429).json({ error: 'Too Many Requests', message: `You have exceeded the rate limit. Please try again in ${remainingTime} seconds.`, remainingTime: remainingTime }); if (req.rateLimit.remaining === 0 && !rateLimiterWarnings.has(ip)) { rateLimiterWarnings.add(ip); console.log(`${colors.red(`[ERROR ${new Date()}]`)} ${req.headers["user-agent"]}@${ip} exceeded rate limit!`); setTimeout(() => rateLimiterWarnings.delete(ip), req.rateLimit.resetTime - Date.now()); } } })); } app.get('/check', (req, res) => { // Check that all required parameters are present if (!req.query.address) { res.send({ "error": "Missing required parameter: address" }); return; }; // Regex for IP address : port const ipRegex = /(?:[0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]{1,5}/; // Check ip argument is valid if (ipRegex.test(req.query.address)) { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${req.headers["user-agent"]}@${req.ip} requested check server ${req.query.address}`); console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Checking server ${req.query.address}`); Steam.queryGameServerInfo(req.query.address).then(data => { // Check if server is not running Stormworks, in which case, someone is trying to be funny if (data.gameId != "573090") { res.status(418).send({ "error": "A server was found, but it is not running Stormworks" }); console.log(`${colors.red(`[ERROR ${new Date()}]`)} Server ${req.query.address} is not running Stormworks! (AppID: ${data.appid})`); return; } data.keywords.split("-") data.address = req.query.address.split(":"); data.serverInfo = splitKeyword(data.keywords); output = { "name": data.name, "address": data.address[0], "port": data.address[1], "password": data.visibility == 1 ? false : true, "version": data.serverInfo.version, "outdated": data.serverInfo.version < serverList.highestVersion ? true : false, "dlc": data.serverInfo.dlc, "dlcString": data.serverInfo.dlcString, "tps": data.serverInfo.tps, "players": data.bots, "maxPlayers": data.maxPlayers, "map": data.map, "gameId": data.gameId, "lastUpdated": new Date() } // Check if server is in errored list or offline list, if so, remove it if (serverList.errored[req.query.address]) { delete serverList.errored[req.query.address]; } if (serverList.offline[req.query.address]) { delete serverList.offline[req.query.address]; } // Add server to server list serverList.servers[req.query.address] = output; res.setHeader("Content-Type", "application/json").send(JSON.stringify(output)); }).catch(err => { console.log(err) res.status(500).send(`Could not query server: ${err}`); }); } else { res.status(400).send("Invalid Server Address, must be in the format IP:PORT") } }); // Commented out because it could be abused, not that the serverList API couldnt be abused anyway // app.get('/masterList', (req, res) => { // res.setHeader("Content-Type", "application/json").send(JSON.stringify(masterList)); // }); app.get('/serverList', (req, res) => { res.setHeader("Content-Type", "application/json").send(JSON.stringify(serverList)); }); app.get('/', (req, res) => { // Send list of all endpoints res.setHeader("Content-Type", "application/json").send(JSON.stringify({ "endpoints": [ "/check?address=IP:PORT", "/serverList" ], "about": { "author": "Chris Chrome", // Get repo "repo": getGitCommitDetails() }, // Rate limit X requests per Y minutes per IP, as a string "rateLimit": `${config.rateLimitMax} requests per ${config.rateLimitWindow} minutes`, "debug": { // "yourIP" Either the IP of the user, or the IP of the proxy if one is used, proxy IP header is x-real-ip "yourIP": req.headers["x-real-ip"] || req.ip, "yourUserAgent": req.headers["user-agent"], } })); }); // Basic robots.txt, deny all app.get('/robots.txt', (req, res) => { res.send("User-agent: *\nDisallow: /"); }); app.listen(port, () => { console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Server started on port ${port}`); });