mc-server-manager/agent/source/app.d

237 lines
7.5 KiB
D

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;
}