/** * Monitor * Pokemon Showdown - http://pokemonshowdown.com/ * * Various utility functions to make sure PS is running healthily. * * @license MIT license */ 'use strict'; const FS = require('./fs'); const MONITOR_CLEAN_TIMEOUT = 2 * 60 * 60 * 1000; /** * This counts the number of times an action has been committed, and tracks the * delta of time since the last time it was committed. Actions include * connecting to the server, starting a battle, validating a team, and * sending/receiving data over a connection's socket. * @augments {Map} */ // @ts-ignore TypeScript bug class TimedCounter extends Map { /** * Increments the number of times an action has been committed by one, and * updates the delta of time since it was last committed. * * @param {string} key * @param {number} timeLimit * @return {[number, number]} - [action count, time delta] */ increment(key, timeLimit) { let val = this.get(key); let now = Date.now(); if (!val || now > val[1] + timeLimit) { this.set(key, [1, Date.now()]); return [1, 0]; } else { val[0]++; return [val[0], now - val[1]]; } } } // Config.loglevel is: // 0 = everything // 1 = debug (same as 0 for now) // 2 = notice (default) // 3 = warning // (4 is currently unused) // 5 = supposedly completely silent, but for now a lot of PS output doesn't respect loglevel if (('Config' in global) && (typeof Config.loglevel !== 'number' || Config.loglevel < 0 || Config.loglevel > 5)) { Config.loglevel = 2; } // @ts-ignore const Monitor = module.exports = { /********************************************************* * Logging *********************************************************/ /** * @param {string} text */ log(text) { this.notice(text); if (Rooms('staff')) { Rooms('staff').add(`|c|~|${text}`).update(); } }, /** * @param {string} text */ adminlog(text) { this.notice(text); if (Rooms('upperstaff')) { Rooms('upperstaff').add(`|c|~|${text}`).update(); } }, /** * @param {string} text */ logHTML(text) { this.notice(text); if (Rooms('staff')) { Rooms('staff').add(`|html|${text}`).update(); } }, /** * @param {string} text */ debug(text) { if (Config.loglevel <= 1) console.log(text); }, /** * @param {string} text */ warn(text) { if (Config.loglevel <= 3) console.log(text); }, /** * @param {string} text */ notice(text) { if (Config.loglevel <= 2) console.log(text); }, /********************************************************* * Resource Monitor *********************************************************/ clean() { this.clearNetworkUse(); this.battlePreps.clear(); this.battles.clear(); this.connections.clear(); Dnsbl.cache.clear(); }, connections: new TimedCounter(), battles: new TimedCounter(), battlePreps: new TimedCounter(), groupChats: new TimedCounter(), /** @type {?string} */ activeIp: null, networkUse: {}, networkCount: {}, hotpatchLock: false, /** * Counts a connection. Returns true if the connection should be terminated for abuse. * * @param {string} ip * @param {string} [name = ''] * @return {boolean} */ countConnection(ip, name = '') { let [count, duration] = this.connections.increment(ip, 30 * 60 * 1000); if (count === 500) { this.adminlog(`[ResourceMonitor] IP ${ip} banned for cflooding (${count} times in ${Chat.toDurationString(duration)}${name ? `: ${name}` : ''})`); return true; } if (count > 500) { if (count % 500 === 0) { let c = count / 500; if (c === 2 || c === 4 || c === 10 || c === 20 || c % 40 === 0) { this.adminlog(`[ResourceMonitor] IP ${ip} still cflooding (${count} times in ${Chat.toDurationString(duration)}${name ? `: ${name}` : ''})`); } } return true; } return false; }, /** * Counts battles created. Returns true if the connection should be * terminated for abuse. * * @param {string} ip * @param {string} [name = ''] * @return {boolean} */ countBattle(ip, name = '') { let [count, duration] = this.battles.increment(ip, 30 * 60 * 1000); if (duration < 5 * 60 * 1000 && count % 30 === 0) { this.adminlog(`[ResourceMonitor] IP ${ip} has battled ${count} times in the last ${Chat.toDurationString(duration)}${name ? `: name` : ''})`); return true; } if (count % 150 === 0) { this.adminlog('[ResourceMonitor] IP ' + ip + ' has battled ' + count + ' times in the last ' + Chat.toDurationString(duration) + name); return true; } return false; }, /** * Counts team validations. Returns true if too many. * * @param {string} ip * @param {Connection} connection * @return {boolean} */ countPrepBattle(ip, connection) { let count = this.battlePreps.increment(ip, 3 * 60 * 1000)[0]; if (count <= 12) return false; if (count < 120 && Punishments.sharedIps.has(ip)) return false; connection.popup('Due to high load, you are limited to 12 battles and team validations every 3 minutes.'); return true; }, /** * Counts concurrent battles. Returns true if too many. * * @param {number} count * @param {Connection} connection * @return {boolean} */ countConcurrentBattle(count, connection) { if (count <= 5) return false; connection.popup(`Due to high load, you are limited to 5 games at the same time.`); return true; }, /** * Counts group chat creation. Returns true if too much. * * @param {string} ip * @return {boolean} */ countGroupChat(ip) { let count = this.groupChats.increment(ip, 60 * 60 * 1000)[0]; return count > 4; }, /** * Counts the data length received by the last connection to send a * message, as well as the data length in the server's response. * * @param {number} size */ countNetworkUse(size) { if (Config.emergency && this.activeIp) { if (this.activeIp in this.networkUse) { this.networkUse[this.activeIp] += size; this.networkCount[this.activeIp]++; } else { this.networkUse[this.activeIp] = size; this.networkCount[this.activeIp] = 1; } } }, writeNetworkUse() { let buf = ''; for (let i in this.networkUse) { buf += `${this.networkUse[i]}\t${this.networkCount[i]}\t${i}\n`; } FS('logs/networkuse.tsv').write(buf); }, clearNetworkUse() { if (Config.emergency) { this.networkUse = {}; this.networkCount = {}; } }, /** * Counts roughly the size of an object to have an idea of the server load. * * @param {any} object * @return {number} */ sizeOfObject(object) { /** @type {Set<(Array | Object)>} */ let objectCache = new Set(); let stack = [object]; let bytes = 0; while (stack.length) { let value = stack.pop(); switch (typeof value) { case 'boolean': bytes += 4; break; case 'string': bytes += value.length * 2; break; case 'number': bytes += 8; break; case 'object': if (!objectCache.has(value)) objectCache.add(value); if (Array.isArray(value)) { for (let el of value) stack.push(el); } else { for (let i in value) stack.push(value[i]); } break; } } return bytes; }, /** @type {{new(entries: [any, [number, number]]): TimedCounter}} */ TimedCounter, }; Monitor.cleanInterval = setInterval(() => Monitor.clean(), MONITOR_CLEAN_TIMEOUT);