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.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; } /** * This program will run frequently (maybe every minute or more), and does the following: * - Fetches a list of requested servers from the mc-server-manager API, and starts them. * - Checks for any servers without any players online, and writes a blank file to track. * - For servers with nobody online, and a timestamp file older than N minutes, the server is shut down. */ void main() { AgentConfig config = readConfig(); ServerInfo[] servers = getServers(); ServerStatus[] statuses = servers.map!(getStatus).array; try { sendServerStatusToWeb(statuses, config); } catch (Exception e) { stderr.writeln(e.msg); } checkForEmptyServers(servers, statuses, config); try { startRequestedServers(servers, config); } catch (Exception e) { stderr.writeln(e.msg); } } void sendServerStatusToWeb(ServerStatus[] statuses, AgentConfig config) { import std.json; import requests; JSONValue jsonPayload = serializeServerStatuses(statuses); string payload = jsonPayload.toJSON(); Request rq = Request(); rq.addHeaders(["X-Agent-Key": config.agentKey]); Response resp = rq.post(config.webUrl ~ "/api/servers", payload, "application/json"); if (resp.code >= 300) { import std.format; throw new Exception( format!"Failed to post server statuses: Error code %d, Response body: %s"( resp.code, resp.responseBody ) ); } } void startRequestedServers(ServerInfo[] 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; } 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)); } } } } void checkForEmptyServers(ServerInfo[] 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); } } } } 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; } } 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; }