diff --git a/README.md b/README.md index 4744401..98c3c57 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,20 @@ following API endpoints are defined: This application is deployed to run under a reverse proxy, like Nginx. Call the `deploy-web.sh` script to deploy it to Andrew's main server. +Also, create an `api-config.properties` file in the server's working directory, +and populated it with values like so: +```properties +agentKey=superSecretAgentKey +clientKey=superSecretClientKey +discordWebhookUrl=https://discord.com/api/webhooks/1234 +``` + ## Agent The agent will periodically inspect the status of all servers, and send that data to the web server. The agent will also automatically shutdown servers -that have been inactive for more than 15 minutes. +that have been inactive for more than a configured time (see **Deployment** +below for how to configure that). More precisely, the agent will activate periodically, and each time perform the following activites: @@ -49,3 +58,31 @@ servers are installed, and it is linked as a SystemD service (but not enabled), and then a SystemD timer is activated to trigger it to run every so often. Run `deploy-agent.sh` to deploy the agent to the server. + +You should make sure there's an `agent-config.properties` file with the +following values: +```properties +webUrl=https://mc-servers.andrewlalis.com +discordWebhookUrl=https://discord.com/api/webhooks/1234 +agentKey=superSecretAgentKey +serverInactivityTimeoutMinutes=30 +``` + +And additionally, a `servers.json` file to define the servers that the agent +will control, formatted like so: +```json +[ + { + "name": "survival-server", + "displayName": "My Survival Server", + "directory": "/home/andrew/minecraft/servers/survival", + "description": "My personal survival server." + }, + { + "name": "creative-server", + "displayName": "My Creative Server", + "directory": "/home/andrew/minecraft/servers/creative", + "description": "A creative server for me to mess around in." + } +] +``` diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..a4131bc --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,2 @@ +agent +agent-test-library diff --git a/agent/source/app.d b/agent/source/app.d index b50b065..94f504c 100644 --- a/agent/source/app.d +++ b/agent/source/app.d @@ -1,42 +1,16 @@ import std.stdio; -import std.json; import std.algorithm; -import std.string; import std.array; -import std.process; -import std.file; -import std.path; +import std.json; import std.datetime; -import std.conv; import requests; -import mcrcd; import shared_utils.server_status; -import shared_utils.discord; -/** - * The set of information about each server that we can obtain by reading the - * server's directory and files in it (service file, properties, etc.). - */ -struct ServerInfo { - /// The name of the server, as declared in servers.json. - string name; - /// The directory to find the server's files in. - string directory; - /// The name of the server's SystemD service, as determined by searching the server's directory. - string serviceName; - ushort port; - string rconPassword; - ushort rconPort; - int maxPlayers; -} - -struct AgentConfig { - string webUrl = "http://localhost:8080"; - string agentKey = "abc"; - int serverInactivityTimeoutMinutes = 30; -} +import config; +import server_metadata; +import server_actions; /** * This program will run frequently (maybe every minute or more), and does the following: @@ -46,8 +20,8 @@ struct AgentConfig { */ void main() { AgentConfig config = readConfig(); - ServerInfo[] servers = getServers(); - ServerStatus[] statuses = servers.map!(getStatus).array; + ServerMetaData[] servers = readServerMetaData(); + ServerStatus[] statuses = servers.map!(determineStatus).array; try { sendServerStatusToWeb(statuses, config); } catch (Exception e) { @@ -61,9 +35,7 @@ void main() { } } -void sendServerStatusToWeb(ServerStatus[] statuses, AgentConfig config) { - import std.json; - import requests; +void sendServerStatusToWeb(in ServerStatus[] statuses, in AgentConfig config) { JSONValue jsonPayload = serializeServerStatuses(statuses); string payload = jsonPayload.toJSON(); Request rq = Request(); @@ -80,157 +52,29 @@ void sendServerStatusToWeb(ServerStatus[] statuses, AgentConfig config) { } } -void startRequestedServers(ServerInfo[] servers, AgentConfig config) { +void startRequestedServers(ServerMetaData[] servers, AgentConfig config) { auto content = getContent(config.webUrl ~ "/api/server-requests"); JSONValue jsonContent = parseJSON(cast(string) content.data); - string[] serverNames; - foreach (item; jsonContent.array) { - serverNames ~= item.str; - } - + string[] requestedServerNames = jsonContent.array() + .map!(node => node.str) + .array; foreach (server; servers) { - foreach (serverName; serverNames) { - if (serverName == server.name) { - writeln("Starting server " ~ serverName); - Pid pid = spawnProcess(["sudo", "systemctl", "start", server.serviceName]); - int result = wait(pid); - if (result != 0) { - writefln!"Starting server %s failed with code %d"(serverName, result); - throw new Exception("Failed to start server."); - } - import std.format; - sendDiscordMessage(format!"Started server %s as a result of a user request."(serverName)); - } + if (canFind(requestedServerNames, server.name)) { + startServer(server, config); } } } -void checkForEmptyServers(ServerInfo[] servers, ServerStatus[] statuses, AgentConfig config) { +void checkForEmptyServers(ServerMetaData[] servers, ServerStatus[] statuses, AgentConfig config) { foreach (i, server; servers) { ServerStatus status = statuses[i]; - const string idleTrackerFile = "agent-idle-tracker__" ~ server.name ~ ".txt"; - if (std.file.exists(idleTrackerFile)) { - // Tracker file already exists. - if (status.online && status.playersOnline > 0) { - // Players are active, remove the tracker file. - std.file.remove(idleTrackerFile); - writeln("Removed idle tracker for server " ~ server.name); - } else if (status.online && status.playersOnline == 0) { - // No players are active, check if trackerfile is older than N minutes. - SysTime timestamp = std.file.timeLastModified(idleTrackerFile); - Duration dur = Clock.currTime - timestamp; - writefln!"Server %s has been idle for at least %d minutes"(server.name, dur.total!"minutes"); - if (dur.total!"minutes" > config.serverInactivityTimeoutMinutes) { - // Inactivity for too long, so shut down the server. - writeln("Shutting down server " ~ server.name ~ " after a period of inactivity."); - import std.process; - Pid pid = spawnProcess(["sudo", "systemctl", "stop", server.serviceName]); - int result = wait(pid); - if (result == 0) { - std.file.remove(idleTrackerFile); - import std.format; - sendDiscordMessage(format!"Shut down server %s after inactivity for more than %d minutes."( - server.name, - config.serverInactivityTimeoutMinutes - )); - } else { - stderr.writefln!"Failed to stop server %s. systemctl stop exited with code %d."(server.name, result); - } - } - } + if (status.online && status.playersOnline > 0) { + removeIdleTrackerFileIfPresent(server); } else if (status.online && status.playersOnline == 0) { - // Create new tracker file. - File f = File(idleTrackerFile, "w"); - f.close(); - writeln("Created idle tracker for server " ~ server.name ~ " because nobody is online."); - } - } -} - -ServerInfo[] getServers() { - import std.json; - import properd; - - JSONValue arr = parseJSON(readText("servers.json")); - return arr.array.map!((s) { - ServerInfo info; - info.name = s.object["name"].str; - info.directory = s.object["directory"].str; - string propsFile = buildPath(info.directory, "server.properties"); - if (!exists(propsFile)) throw new Exception("Missing server properties file: " ~ propsFile); - try { - auto props = readProperties(propsFile); - info.port = props.as!(ushort)("server-port"); - info.rconPassword = props["rcon.password"]; - info.rconPort = props.as!(ushort)("rcon.port"); - info.maxPlayers = props.as!(int)("max-players"); - } catch (PropertyException e) { - stderr.writefln!"Error parsing properties from %s: %s"(propsFile, e.msg); - throw e; - } - - info.serviceName = null; - foreach (DirEntry entry; dirEntries(info.directory, SpanMode.shallow, false)) { - if (entry.name.endsWith(".service")) { - info.serviceName = baseName(stripExtension(entry.name)); - break; + const Duration idleTime = getOrCreateIdleTrackerFileAndGetAge(server); + if (idleTime.total!"minutes" > config.serverInactivityTimeoutMinutes) { + stopServer(server, config); } } - if (info.serviceName is null) throw new Exception("No SystemD service file found in " ~ info.directory); - - return info; - }).array; -} - -ServerStatus getStatus(ServerInfo server) { - ServerStatus status; - status.identifier = server.name; - status.name = server.name; // TODO: Add display name to servers.json. - status.online = isServiceActive(server.serviceName); - status.maxPlayers = server.maxPlayers; - if (status.online) { - MCRconResponse response = executeRconCommand(server, "list"); - string playersList; - int tmp; - import std.format; - response.text.formattedRead!"There are %d of a max of %d players online: %s"( - status.playersOnline, - tmp, - playersList - ); - status.playerNames = playersList.strip.split(",") - .filter!(s => s !is null && s.strip.length > 0) - .map!(s => s.strip) - .array; } - return status; -} - -bool isServiceActive(string serviceName) { - import std.process; - Pid pid = spawnProcess(["systemctl", "is-active", "--quiet", serviceName]); - int result = wait(pid); - return result == 0; -} - -MCRconResponse executeRconCommand(ServerInfo server, string command) { - MCRcon rcon = new MCRcon(); - rcon.connect("127.0.0.1", server.rconPort); - scope(exit) { - rcon.disconnect(); - } - rcon.login(server.rconPassword); - return rcon.command(command); -} - -AgentConfig readConfig() { - import properd; - AgentConfig config; - if (std.file.exists("agent-config.properties")) { - auto props = readProperties("agent-config.properties"); - config.webUrl = props["webUrl"]; - config.agentKey = props["agentKey"]; - config.serverInactivityTimeoutMinutes = props.as!(int)("serverInactivityTimeoutMinutes"); - } - return config; } diff --git a/agent/source/config.d b/agent/source/config.d new file mode 100644 index 0000000..0520e36 --- /dev/null +++ b/agent/source/config.d @@ -0,0 +1,24 @@ +module config; + +/// The configuration data available in a "agent-config.properties" file in the agent's working directory. +struct AgentConfig { + const string webUrl = "http://localhost:8080"; + const string discordWebhookUrl = null; + const string agentKey = "abc"; + const int serverInactivityTimeoutMinutes = 30; +} + +AgentConfig readConfig() { + import properd; + import std.file : exists; + if (exists("agent-config.properties")) { + auto props = readProperties("agent-config.properties"); + return AgentConfig( + props["webUrl"], + props["discordWebhookUrl"], + props["agentKey"], + props.as!(int)("serverInactivityTimeoutMinutes") + ); + } + throw new Exception("Missing agent-config.properties"); +} diff --git a/agent/source/server_actions.d b/agent/source/server_actions.d new file mode 100644 index 0000000..8e215a4 --- /dev/null +++ b/agent/source/server_actions.d @@ -0,0 +1,121 @@ +module server_actions; + +import std.stdio; +import std.process; +import std.format; +import std.algorithm; +import std.array; +import std.string; +import std.file; +import std.datetime; +import mcrcd; + +import shared_utils.discord; +import shared_utils.server_status; + +import server_metadata; +import config; + +ServerStatus determineStatus(in ServerMetaData server) { + const bool online = isServiceActive(server.serviceName); + int playersOnline = 0; + string[] playerNames; + if (online) { + MCRconResponse response = executeRconCommand(server, "list"); + string playersList; + int tmp; + response.text.formattedRead!"There are %d of a max of %d players online: %s"( + playersOnline, + tmp, + playersList + ); + playerNames = playersList.strip.split(",") + .filter!(s => s !is null && s.strip.length > 0) + .map!(s => s.strip) + .array; + } + return ServerStatus( + server.name, + server.displayName, + server.description, + online, + playersOnline, + server.maxPlayers, + playerNames.idup + ); +} + +void startServer(in ServerMetaData server, in AgentConfig config) { + writeln("Starting server " ~ server.name); + Pid pid = spawnProcess(["sudo", "systemctl", "start", server.serviceName]); + int result = wait(pid); + if (result != 0) { + string msg = format!"Starting server %s failed with code %d."(server.name, result); + stderr.writeln(msg); + throw new Exception(msg); + } + sendDiscordMessage( + config.discordWebhookUrl, + format!"Started server %s as a result of a user request."(server.name) + ); +} + +void stopServer(in ServerMetaData server, in AgentConfig config) { + writeln("Shutting down server " ~ server.name ~ " after a period of inactivity."); + Pid pid = spawnProcess(["sudo", "systemctl", "stop", server.serviceName]); + int result = wait(pid); + if (result == 0) { + removeIdleTrackerFileIfPresent(server); + sendDiscordMessage( + config.discordWebhookUrl, + format!"Shut down server %s after inactivity for more than %d minutes."( + server.name, + config.serverInactivityTimeoutMinutes + ) + ); + } else { + stderr.writefln!"Failed to stop server %s. systemctl stop exited with code %d."(server.name, result); + } +} + +Duration getOrCreateIdleTrackerFileAndGetAge(in ServerMetaData server) { + string filename = getIdleTrackerFilename(server); + if (exists(filename)) { + SysTime timestamp = std.file.timeLastModified(filename); + return Clock.currTime - timestamp; + } else { + File f = File(filename, "w"); + f.close(); + writeln("Created idle tracker for server " ~ server.name ~ "."); + return seconds(0); + } +} + +void removeIdleTrackerFileIfPresent(in ServerMetaData server) { + string trackerFile = getIdleTrackerFilename(server); + if (exists(trackerFile)) { + std.file.remove(trackerFile); + writeln("Removed idle tracker for server " ~ server.name); + } +} + +private MCRconResponse executeRconCommand(in ServerMetaData server, string command) { + MCRcon rcon = new MCRcon(); + rcon.connect("127.0.0.1", server.rconPort); + scope(exit) { + rcon.disconnect(); + } + rcon.login(server.rconPassword); + return rcon.command(command); +} + +private bool isServiceActive(string serviceName) { + import std.process; + Pid pid = spawnProcess(["systemctl", "is-active", "--quiet", serviceName]); + int result = wait(pid); + return result == 0; +} + +private string getIdleTrackerFilename(in ServerMetaData server) { + return "agent-idle-tracker__" ~ server.name ~ ".txt"; +} diff --git a/agent/source/server_metadata.d b/agent/source/server_metadata.d new file mode 100644 index 0000000..9e41448 --- /dev/null +++ b/agent/source/server_metadata.d @@ -0,0 +1,86 @@ +module server_metadata; + +import std.json; +import std.file; +import std.path; +import std.string : endsWith; +import std.array; +import std.algorithm; +import std.stdio; + +import properd; + +/** + * The set of information about each server that we can obtain by reading the + * server's directory and files in it (service file, properties, etc.). + */ +struct ServerMetaData { + /// The name of the server, as declared in servers.json. + const string name; + /// The display name of the server, as declared in servers.json. + const string displayName; + /// The description of the server, as declared in servers.json. + const string description; + /// The directory to find the server's files in. + const string directory; + /// The name of the server's SystemD service, as determined by searching the server's directory. + const string serviceName; + /// The port used for player connections. + const ushort port; + /// The password for connecting to the RCON service of the server. + const string rconPassword; + /// The port for connecting to the RCON service. + const ushort rconPort; + /// The maximum number of players for the server. + const int maxPlayers; +} + +/** + * Reads server metadata from each server defined in "servers.json", by parsing + * its "server.properties" file, SystemD service file, and more. + * Returns: The list of server metadata structs. + */ +ServerMetaData[] readServerMetaData() { + import properd; + + JSONValue arr = parseJSON(readText("servers.json")); + return arr.array.map!((s) { + string name = s.object["name"].str; + string displayName = s.object["displayName"].str; + string description = s.object["description"].str; + string directory = s.object["directory"].str; + string systemDServiceName = findSystemDServiceName(directory); + string propsFile = buildPath(directory, "server.properties"); + if (!exists(propsFile)) throw new Exception("Missing server properties file: " ~ propsFile); + try { + auto props = readProperties(propsFile); + ushort port = props.as!(ushort)("server-port"); + string rconPassword = props["rcon.password"]; + ushort rconPort = props.as!(ushort)("rcon.port"); + int maxPlayers = props.as!(int)("max-players"); + return ServerMetaData( + name, + displayName, + description, + directory, + systemDServiceName, + port, + rconPassword, + rconPort, + maxPlayers + ); + } catch (PropertyException e) { + stderr.writefln!"Error parsing properties from %s: %s"(propsFile, e.msg); + throw e; + } + }).array; +} + +private string findSystemDServiceName(string serverDir) { + foreach (DirEntry entry; dirEntries(serverDir, SpanMode.shallow, false)) { + if (entry.name.endsWith(".service")) { + return baseName(stripExtension(entry.name)); + } + } + throw new Exception("No SystemD service file found in " ~ serverDir); +} diff --git a/api/app/index.html b/api/app/index.html index 46526b8..2f67aae 100644 --- a/api/app/index.html +++ b/api/app/index.html @@ -33,6 +33,7 @@ +

Request to start the selected server.

@@ -41,14 +42,15 @@
- - + +
+

@@ -67,6 +69,10 @@ header.innerText = serverObj.name; div.appendChild(header); + const description = document.createElement("p"); + description.innerText = serverObj.description; + div.appendChild(description); + const playersList = document.createElement("p"); let text = "Players: "; if (serverObj.playerNames.length == 0) { @@ -101,15 +107,15 @@ if (!serverObj.online && !serverObj.startupRequested) { const requestButton = document.createElement("button"); requestButton.innerText = "Request to start this server"; - requestButton.addEventListener("click", () => openRequestDialog(serverObj.name)); + requestButton.addEventListener("click", () => openRequestDialog(serverObj.identifier)); div.appendChild(requestButton); } return div; } - function openRequestDialog(serverName) { - const serverNameInput = document.getElementById("server-name-input"); - serverNameInput.value = serverName; + function openRequestDialog(serverIdentifier) { + const serverIdentifierInput = document.getElementById("server-identifier-input"); + serverIdentifierInput.value = serverIdentifier; const dialog = document.getElementById("request-dialog"); dialog.showModal(); } @@ -122,15 +128,15 @@ } async function submitServerRequest() { - const serverName = document.getElementById("server-name-input").value; + const serverIdentifier = document.getElementById("server-identifier-input").value; const clientKey = document.getElementById("client-key-input").value; - if (typeof(serverName) !== "string" || typeof(clientKey) !== "string") { + if (typeof(serverIdentifier) !== "string" || typeof(clientKey) !== "string") { return; } const dialog = document.getElementById("request-dialog"); closeRequestDialog(); try { - const response = await fetch(`/api/servers/${serverName}/requests`, { + const response = await fetch(`/api/servers/${serverIdentifier}/requests`, { method: "POST", headers: { "X-Client-Key": clientKey diff --git a/api/dub.json b/api/dub.json index 193dace..3befe6c 100644 --- a/api/dub.json +++ b/api/dub.json @@ -4,7 +4,8 @@ ], "copyright": "Copyright © 2024, Andrew Lalis", "dependencies": { - "handy-httpd": "~>8.4.0", + "handy-httpd": "~>8.4.1", + "proper-d": "~>0.0.2", "requests": "~>2.1.3", "shared-utils": { "path": "../shared-utils" diff --git a/api/dub.selections.json b/api/dub.selections.json index 6853125..43ea820 100644 --- a/api/dub.selections.json +++ b/api/dub.selections.json @@ -3,9 +3,10 @@ "versions": { "automem": "0.6.10", "cachetools": "0.4.1", - "handy-httpd": "8.4.0", + "handy-httpd": "8.4.1", "httparsed": "1.2.1", "path-matcher": "1.2.0", + "proper-d": "0.0.2", "requests": "2.1.3", "shared-utils": {"path":"../shared-utils"}, "slf4d": "3.0.1", diff --git a/api/source/app.d b/api/source/app.d index c4edd93..42b1feb 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -18,12 +18,22 @@ import core.sync.mutex; import shared_utils.server_status; import shared_utils.discord; +const AGENT_KEY_HEADER = "X-Agent-Key"; +const CLIENT_KEY_HEADER = "X-Client-Key"; + __gshared ServerStatus[] serverStatuses; __gshared Mutex serversMutex; -__gshared string agentKey = "abc"; -__gshared string clientKey = "abc"; +__gshared ApiConfig config; + +struct ApiConfig { + string agentKey; + string clientKey; + string discordWebhookUrl; +} void main() { + config = readConfig(); + auto provider = new DefaultProvider(false, Levels.INFO); configureLoggingProvider(provider); @@ -39,22 +49,19 @@ void main() { DirectoryResolutionStrategies.serveIndexFiles )); - ServerConfig config; - config.connectionQueueSize = 20; - config.receiveBufferSize = 4096; - config.workerPoolSize = 3; - config.port = 8105; - HttpServer server = new HttpServer(handler, config); + ServerConfig serverConfig; + serverConfig.connectionQueueSize = 20; + serverConfig.receiveBufferSize = 4096; + serverConfig.workerPoolSize = 3; + serverConfig.port = 8105; + HttpServer server = new HttpServer(handler, serverConfig); server.start(); } /// Called when the agent posts the server status to us. void postServerStatus(ref HttpRequestContext ctx) { - Optional!string key = ctx.request.headers.getFirst("X-Agent-Key"); - if (!key || key.value != agentKey) { - ctx.response.status = HttpStatus.UNAUTHORIZED; - return; - } + checkKey(ctx, AGENT_KEY_HEADER, config.agentKey); + JSONValue jsonBody = ctx.request.readBodyAsJson(); serversMutex.lock(); scope(exit) serversMutex.unlock(); @@ -78,11 +85,7 @@ void listServers(ref HttpRequestContext ctx) { /// Called by a user when they request to start a server. void requestServerStartup(ref HttpRequestContext ctx) { - Optional!string key = ctx.request.headers.getFirst("X-Client-Key"); - if (!key || key.value != clientKey) { - ctx.response.status = HttpStatus.UNAUTHORIZED; - return; - } + checkKey(ctx, CLIENT_KEY_HEADER, config.clientKey); string identifier = ctx.request.getPathParamAs!string("id"); serversMutex.lock(); @@ -92,7 +95,10 @@ void requestServerStartup(ref HttpRequestContext ctx) { File f = File("request_" ~ identifier ~ ".txt", "w"); f.writeln(Clock.currTime().toISOExtString()); f.close(); - sendDiscordMessage(format!"User requested to start server %s."(server.identifier)); + sendDiscordMessage( + config.discordWebhookUrl, + format!"User requested to start server %s."(server.identifier) + ); return; } } @@ -115,3 +121,22 @@ void getServerRequests(ref HttpRequestContext ctx) { bool isStartupRequested(string id) { return std.file.exists("request_" ~ id ~ ".txt"); } + +void checkKey(ref HttpRequestContext ctx, string keyHeaderName, string expectedValue) { + Optional!string key = ctx.request.headers.getFirst(keyHeaderName); + if (!key || key.value != expectedValue) { + throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing key."); + } +} + +ApiConfig readConfig() { + import properd; + import std.file : exists; + if (!exists("api-config.properties")) throw new Exception("Missing api-config.properties"); + auto props = readProperties("api-config.properties"); + return ApiConfig( + props["agentKey"], + props["clientKey"], + props["discordWebhookUrl"] + ); +} diff --git a/shared-utils/README.md b/shared-utils/README.md new file mode 100644 index 0000000..f18d5e3 --- /dev/null +++ b/shared-utils/README.md @@ -0,0 +1,3 @@ +# Shared Utilities + +This directory contains a small D package that is shared among the **agent**, and the **api**, to help coordinate when they must communicate with each other. diff --git a/shared-utils/source/shared_utils/discord.d b/shared-utils/source/shared_utils/discord.d index d67d569..259ebf3 100644 --- a/shared-utils/source/shared_utils/discord.d +++ b/shared-utils/source/shared_utils/discord.d @@ -1,15 +1,14 @@ module shared_utils.discord; -void sendDiscordMessage(string msg) { +void sendDiscordMessage(string webhookUrl, string msg) { import requests; import std.string : strip; import std.stdio; import std.format; - const string WEBHOOK_URL = "https://discord.com/api/webhooks/1242607102011244645/fIBfGz3_Xp_C0EQTymXhcUW6kfde45mo01wvvJ9RerFfItTPX23eu5QUAdulaJBwaQrK"; string payload = "{\"content\": \"" ~ strip(msg) ~ "\"}"; try { Request rq = Request(); - Response resp = rq.post(WEBHOOK_URL, payload, "application/json"); + Response resp = rq.post(webhookUrl, payload, "application/json"); if (resp.code >= 300) { writeln(resp.code); writeln(resp.responseBody); diff --git a/shared-utils/source/shared_utils/server_status.d b/shared-utils/source/shared_utils/server_status.d index 9cc0b7c..cfded09 100644 --- a/shared-utils/source/shared_utils/server_status.d +++ b/shared-utils/source/shared_utils/server_status.d @@ -1,19 +1,37 @@ module shared_utils.server_status; import std.json; +import std.algorithm; +import std.array; +/** + * A struct containing basic information about a single Minecraft server. + */ struct ServerStatus { - string identifier = null; - string name = null; - bool online = false; - int playersOnline = 0; - int maxPlayers = 0; - string[] playerNames = []; + /// A unique identifier for the server, defined by the agent's "servers.json" file. + const string identifier = null; + /// The human-readable name of the server. + const string name = null; + /// A description for the server. + const string description = null; + /// Whether the server is online. + const bool online = false; + /// The number of players online. + const int playersOnline = 0; + /// The maximum number of players that the server allows. + const int maxPlayers = 0; + /// A list of names of all players that are online. + const string[] playerNames = []; - JSONValue toJsonObject() { + /** + * Converts this status to a JSON object. + * Returns: The JSON object. + */ + JSONValue toJsonObject() const { JSONValue obj = JSONValue.emptyObject; obj.object["identifier"] = JSONValue(identifier); obj.object["name"] = JSONValue(name); + obj.object["description"] = JSONValue(description); obj.object["online"] = JSONValue(online); obj.object["playersOnline"] = JSONValue(playersOnline); obj.object["maxPlayers"] = JSONValue(maxPlayers); @@ -24,22 +42,43 @@ struct ServerStatus { return obj; } + /** + * Converts this status to a JSON string. + * Returns: The JSON string. + */ + string toJsonString() const { + JSONValue obj = toJsonObject(); + return obj.toJSON(); + } + + /** + * Parses a status from a JSON object. Throws a JSONException in case of + * errors in the provided data. + * Params: + * obj = The JSON object. + * Returns: The server status. + */ static ServerStatus fromJsonObject(JSONValue obj) { if (obj.type != JSONType.OBJECT) throw new JSONException("JSON value is not an object."); - ServerStatus s; - s.identifier = obj.object["identifier"].str; - s.name = obj.object["name"].str; - s.online = obj.object["online"].boolean; - s.playersOnline = cast(int) obj.object["playersOnline"].integer; - s.maxPlayers = cast(int) obj.object["maxPlayers"].integer; - foreach (node; obj.object["playerNames"].array) { - s.playerNames ~= node.str; - } - return s; + return ServerStatus( + obj.object["identifier"].str, + obj.object["name"].str, + obj.object["description"].str, + obj.object["online"].boolean, + cast(int) obj.object["playersOnline"].integer, + cast(int) obj.object["maxPlayers"].integer, + obj.object["playerNames"].array().map!(node => node.str).array + ); } } -JSONValue serializeServerStatuses(ServerStatus[] statuses) { +/** + * Serializes a list of server statuses into a single JSON array. + * Params: + * statuses = The statuses to serialize. + * Returns: The JSON array. + */ +JSONValue serializeServerStatuses(in ServerStatus[] statuses) { JSONValue arr = JSONValue.emptyArray; foreach (s; statuses) { arr.array ~= s.toJsonObject(); @@ -47,11 +86,13 @@ JSONValue serializeServerStatuses(ServerStatus[] statuses) { return arr; } -ServerStatus[] deserializeServerStatuses(JSONValue arr) { +/** + * Deserializes a list of server statuses from a JSON array. + * Params: + * arr = The JSON array. + * Returns: The list of server statuses. + */ +ServerStatus[] deserializeServerStatuses(in JSONValue arr) { if (arr.type != JSONType.ARRAY) throw new JSONException("JSON value is not an array."); - ServerStatus[] statuses = new ServerStatus[arr.array.length]; - for (size_t i = 0; i < arr.array.length; i++) { - statuses[i] = ServerStatus.fromJsonObject(arr.array[i]); - } - return statuses; + return arr.array().map!(obj => ServerStatus.fromJsonObject(obj)).array; }