237 lines
7.5 KiB
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;
|
|
}
|