const crypto = require("crypto"); const DNSResolver = require("./dns"); const Server = require("./server"); const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require("./time"); const { getPlayerCountOrNull } = require("./util"); const config = require("../config"); const minecraftVersions = require("../minecraft_versions"); class ServerRegistration { serverId; lastFavicon; versions = []; recordData; graphData = []; constructor(app, serverId, data) { this._app = app; this.serverId = serverId; this.data = data; this._pingHistory = []; this.dnsResolver = new DNSResolver(this.data.ip, this.data.port); } handlePing(timestamp, resp, err, version, updateHistoryGraph) { // Use null to represent a failed ping const unsafePlayerCount = getPlayerCountOrNull(resp); // Store into in-memory ping data TimeTracker.pushAndShift( this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength() ); // Only notify the frontend to append to the historical graph // if both the graphing behavior is enabled and the backend agrees // that the ping is eligible for addition if (updateHistoryGraph) { TimeTracker.pushAndShift( this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength() ); } // Delegate out update payload generation return this.getUpdate(timestamp, resp, err, version); } getUpdate(timestamp, resp, err, version) { const update = {}; // Always append a playerCount value // When resp is undefined (due to an error), playerCount will be null update.playerCount = getPlayerCountOrNull(resp); if (resp) { if ( resp.version && this.updateProtocolVersionCompat( resp.version, version.protocolId, version.protocolIndex ) ) { // Append an updated version listing update.versions = this.versions; } if ( config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount) ) { this.recordData = { playerCount: resp.players.online, timestamp: TimeTracker.toSeconds(timestamp), }; // Append an updated recordData update.recordData = this.recordData; // Update record in database this._app.database.updatePlayerCountRecord( this.data.ip, resp.players.online, timestamp ); } if (this.updateFavicon(resp.favicon)) { update.favicon = this.getFaviconUrl(); } if (config.logToDatabase) { // Update calculated graph peak regardless if the graph is being updated // This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes if (this.findNewGraphPeak()) { update.graphPeakData = this.getGraphPeak(); } } } else if (err) { // Append a filtered copy of err // This ensures any unintended data is not leaked update.error = this.filterError(err); } return update; } getPingHistory() { if (this._pingHistory.length > 0) { const payload = { versions: this.versions, recordData: this.recordData, favicon: this.getFaviconUrl(), }; // Only append graphPeakData if defined // The value is lazy computed and conditional that config->logToDatabase == true const graphPeakData = this.getGraphPeak(); if (graphPeakData) { payload.graphPeakData = graphPeakData; } // Assume the ping was a success and define result // pingHistory does not keep error references, so its impossible to detect if this is an error // It is also pointless to store that data since it will be short lived payload.playerCount = this._pingHistory[this._pingHistory.length - 1]; // Send a copy of pingHistory // Include the last value even though it is contained within payload // The frontend will only push to its graphData from playerCountHistory payload.playerCountHistory = this._pingHistory; return payload; } return { error: { message: "Pinging...", }, recordData: this.recordData, graphPeakData: this.getGraphPeak(), favicon: this.data.favicon, }; } loadGraphPoints(startTime, timestamps, points) { this.graphData = TimeTracker.everyN( timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i] ); } findNewGraphPeak() { let index = -1; for (let i = 0; i < this.graphData.length; i++) { const point = this.graphData[i]; if (point !== null && (index === -1 || point > this.graphData[index])) { index = i; } } if (index >= 0) { const lastGraphPeakIndex = this._graphPeakIndex; this._graphPeakIndex = index; return index !== lastGraphPeakIndex; } else { this._graphPeakIndex = undefined; return false; } } getGraphPeak() { if (this._graphPeakIndex === undefined) { return; } return { playerCount: this.graphData[this._graphPeakIndex], timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex), }; } updateFavicon(favicon) { // If data.favicon is defined, then a favicon override is present // Disregard the incoming favicon, regardless if it is different if (this.data.favicon) { return false; } if (favicon && favicon !== this.lastFavicon) { this.lastFavicon = favicon; // Generate an updated hash // This is used by #getFaviconUrl this.faviconHash = crypto .createHash("md5") .update(favicon) .digest("hex") .toString(); return true; } return false; } getFaviconUrl() { if (this.faviconHash) { return Server.getHashedFaviconUrl(this.faviconHash); } else if (this.data.favicon) { return this.data.favicon; } } updateProtocolVersionCompat(incomingId, outgoingId, protocolIndex) { // If the result version matches the attempted version, the version is supported const isSuccess = incomingId === outgoingId; const indexOf = this.versions.indexOf(protocolIndex); // Test indexOf to avoid inserting previously recorded protocolIndex values if (isSuccess && indexOf < 0) { this.versions.push(protocolIndex); // Sort versions in ascending order // This matches protocol ids to Minecraft versions release order this.versions.sort((a, b) => a - b); return true; } else if (!isSuccess && indexOf >= 0) { this.versions.splice(indexOf, 1); return true; } return false; } getNextProtocolVersion() { // Minecraft Bedrock Edition does not have protocol versions if (this.data.type === "PE") { return { protocolId: 0, protocolIndex: 0, }; } const protocolVersions = minecraftVersions[this.data.type]; if ( typeof this._nextProtocolIndex === "undefined" || this._nextProtocolIndex + 1 >= protocolVersions.length ) { this._nextProtocolIndex = 0; } else { this._nextProtocolIndex++; } return { protocolId: protocolVersions[this._nextProtocolIndex].protocolId, protocolIndex: this._nextProtocolIndex, }; } filterError(err) { let message = "Unknown error"; // Attempt to match to the first possible value for (const key of ["message", "description", "errno"]) { if (err[key]) { message = err[key]; break; } } // Trim the message if too long if (message.length > 28) { message = message.substring(0, 28) + "..."; } return { message: message, }; } getPublicData() { // Return a custom object instead of data directly to avoid data leakage return { name: this.data.name, ip: this.data.ip, type: this.data.type, color: this.data.color, }; } } module.exports = ServerRegistration;