Improved security by removing hard-coded keys and refactoring agent logic.
This commit is contained in:
parent
807b47137d
commit
82bfb474d8
39
README.md
39
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
|
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.
|
`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
|
## Agent
|
||||||
|
|
||||||
The agent will periodically inspect the status of all servers, and send that
|
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
|
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
|
More precisely, the agent will activate periodically, and each time perform the
|
||||||
following activites:
|
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.
|
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.
|
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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
agent
|
||||||
|
agent-test-library
|
|
@ -1,42 +1,16 @@
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
import std.json;
|
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
import std.string;
|
|
||||||
import std.array;
|
import std.array;
|
||||||
import std.process;
|
import std.json;
|
||||||
import std.file;
|
|
||||||
import std.path;
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.conv;
|
|
||||||
|
|
||||||
import requests;
|
import requests;
|
||||||
import mcrcd;
|
|
||||||
|
|
||||||
import shared_utils.server_status;
|
import shared_utils.server_status;
|
||||||
import shared_utils.discord;
|
|
||||||
|
|
||||||
/**
|
import config;
|
||||||
* The set of information about each server that we can obtain by reading the
|
import server_metadata;
|
||||||
* server's directory and files in it (service file, properties, etc.).
|
import server_actions;
|
||||||
*/
|
|
||||||
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:
|
* This program will run frequently (maybe every minute or more), and does the following:
|
||||||
|
@ -46,8 +20,8 @@ struct AgentConfig {
|
||||||
*/
|
*/
|
||||||
void main() {
|
void main() {
|
||||||
AgentConfig config = readConfig();
|
AgentConfig config = readConfig();
|
||||||
ServerInfo[] servers = getServers();
|
ServerMetaData[] servers = readServerMetaData();
|
||||||
ServerStatus[] statuses = servers.map!(getStatus).array;
|
ServerStatus[] statuses = servers.map!(determineStatus).array;
|
||||||
try {
|
try {
|
||||||
sendServerStatusToWeb(statuses, config);
|
sendServerStatusToWeb(statuses, config);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -61,9 +35,7 @@ void main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendServerStatusToWeb(ServerStatus[] statuses, AgentConfig config) {
|
void sendServerStatusToWeb(in ServerStatus[] statuses, in AgentConfig config) {
|
||||||
import std.json;
|
|
||||||
import requests;
|
|
||||||
JSONValue jsonPayload = serializeServerStatuses(statuses);
|
JSONValue jsonPayload = serializeServerStatuses(statuses);
|
||||||
string payload = jsonPayload.toJSON();
|
string payload = jsonPayload.toJSON();
|
||||||
Request rq = Request();
|
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");
|
auto content = getContent(config.webUrl ~ "/api/server-requests");
|
||||||
JSONValue jsonContent = parseJSON(cast(string) content.data);
|
JSONValue jsonContent = parseJSON(cast(string) content.data);
|
||||||
string[] serverNames;
|
string[] requestedServerNames = jsonContent.array()
|
||||||
foreach (item; jsonContent.array) {
|
.map!(node => node.str)
|
||||||
serverNames ~= item.str;
|
.array;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (server; servers) {
|
foreach (server; servers) {
|
||||||
foreach (serverName; serverNames) {
|
if (canFind(requestedServerNames, server.name)) {
|
||||||
if (serverName == server.name) {
|
startServer(server, config);
|
||||||
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) {
|
void checkForEmptyServers(ServerMetaData[] servers, ServerStatus[] statuses, AgentConfig config) {
|
||||||
foreach (i, server; servers) {
|
foreach (i, server; servers) {
|
||||||
ServerStatus status = statuses[i];
|
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) {
|
if (status.online && status.playersOnline > 0) {
|
||||||
// Players are active, remove the tracker file.
|
removeIdleTrackerFileIfPresent(server);
|
||||||
std.file.remove(idleTrackerFile);
|
|
||||||
writeln("Removed idle tracker for server " ~ server.name);
|
|
||||||
} else if (status.online && status.playersOnline == 0) {
|
} else if (status.online && status.playersOnline == 0) {
|
||||||
// No players are active, check if trackerfile is older than N minutes.
|
const Duration idleTime = getOrCreateIdleTrackerFileAndGetAge(server);
|
||||||
SysTime timestamp = std.file.timeLastModified(idleTrackerFile);
|
if (idleTime.total!"minutes" > config.serverInactivityTimeoutMinutes) {
|
||||||
Duration dur = Clock.currTime - timestamp;
|
stopServer(server, config);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
|
@ -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";
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -33,6 +33,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="request-dialog">
|
<dialog id="request-dialog">
|
||||||
|
<!-- This dialog opens when the user clicks to request to start a server. -->
|
||||||
<p>
|
<p>
|
||||||
Request to start the selected server.
|
Request to start the selected server.
|
||||||
</p>
|
</p>
|
||||||
|
@ -41,14 +42,15 @@
|
||||||
<input type="password" name="client-key" id="client-key-input"/>
|
<input type="password" name="client-key" id="client-key-input"/>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 0.5em">
|
<div style="margin-bottom: 0.5em">
|
||||||
<label for="server-name-input">Server Name</label>
|
<label for="server-identifier-input">Server Identifier</label>
|
||||||
<input type="text" name="server-name" id="server-name-input" disabled/>
|
<input type="text" name="server-identifier" id="server-identifier-input" disabled/>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="submitServerRequest()">Submit</button>
|
<button onclick="submitServerRequest()">Submit</button>
|
||||||
<button onclick="closeRequestDialog()">Cancel</button>
|
<button onclick="closeRequestDialog()">Cancel</button>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="message-dialog">
|
<dialog id="message-dialog">
|
||||||
|
<!-- This dialog is used to show generic messages in the <p> tag below. -->
|
||||||
<p></p>
|
<p></p>
|
||||||
<button onclick="document.getElementById('message-dialog').close()">Close</button>
|
<button onclick="document.getElementById('message-dialog').close()">Close</button>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
@ -67,6 +69,10 @@
|
||||||
header.innerText = serverObj.name;
|
header.innerText = serverObj.name;
|
||||||
div.appendChild(header);
|
div.appendChild(header);
|
||||||
|
|
||||||
|
const description = document.createElement("p");
|
||||||
|
description.innerText = serverObj.description;
|
||||||
|
div.appendChild(description);
|
||||||
|
|
||||||
const playersList = document.createElement("p");
|
const playersList = document.createElement("p");
|
||||||
let text = "Players: ";
|
let text = "Players: ";
|
||||||
if (serverObj.playerNames.length == 0) {
|
if (serverObj.playerNames.length == 0) {
|
||||||
|
@ -101,15 +107,15 @@
|
||||||
if (!serverObj.online && !serverObj.startupRequested) {
|
if (!serverObj.online && !serverObj.startupRequested) {
|
||||||
const requestButton = document.createElement("button");
|
const requestButton = document.createElement("button");
|
||||||
requestButton.innerText = "Request to start this server";
|
requestButton.innerText = "Request to start this server";
|
||||||
requestButton.addEventListener("click", () => openRequestDialog(serverObj.name));
|
requestButton.addEventListener("click", () => openRequestDialog(serverObj.identifier));
|
||||||
div.appendChild(requestButton);
|
div.appendChild(requestButton);
|
||||||
}
|
}
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRequestDialog(serverName) {
|
function openRequestDialog(serverIdentifier) {
|
||||||
const serverNameInput = document.getElementById("server-name-input");
|
const serverIdentifierInput = document.getElementById("server-identifier-input");
|
||||||
serverNameInput.value = serverName;
|
serverIdentifierInput.value = serverIdentifier;
|
||||||
const dialog = document.getElementById("request-dialog");
|
const dialog = document.getElementById("request-dialog");
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
}
|
}
|
||||||
|
@ -122,15 +128,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitServerRequest() {
|
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;
|
const clientKey = document.getElementById("client-key-input").value;
|
||||||
if (typeof(serverName) !== "string" || typeof(clientKey) !== "string") {
|
if (typeof(serverIdentifier) !== "string" || typeof(clientKey) !== "string") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dialog = document.getElementById("request-dialog");
|
const dialog = document.getElementById("request-dialog");
|
||||||
closeRequestDialog();
|
closeRequestDialog();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/servers/${serverName}/requests`, {
|
const response = await fetch(`/api/servers/${serverIdentifier}/requests`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Client-Key": clientKey
|
"X-Client-Key": clientKey
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"handy-httpd": "~>8.4.0",
|
"handy-httpd": "~>8.4.1",
|
||||||
|
"proper-d": "~>0.0.2",
|
||||||
"requests": "~>2.1.3",
|
"requests": "~>2.1.3",
|
||||||
"shared-utils": {
|
"shared-utils": {
|
||||||
"path": "../shared-utils"
|
"path": "../shared-utils"
|
||||||
|
|
|
@ -3,9 +3,10 @@
|
||||||
"versions": {
|
"versions": {
|
||||||
"automem": "0.6.10",
|
"automem": "0.6.10",
|
||||||
"cachetools": "0.4.1",
|
"cachetools": "0.4.1",
|
||||||
"handy-httpd": "8.4.0",
|
"handy-httpd": "8.4.1",
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"path-matcher": "1.2.0",
|
"path-matcher": "1.2.0",
|
||||||
|
"proper-d": "0.0.2",
|
||||||
"requests": "2.1.3",
|
"requests": "2.1.3",
|
||||||
"shared-utils": {"path":"../shared-utils"},
|
"shared-utils": {"path":"../shared-utils"},
|
||||||
"slf4d": "3.0.1",
|
"slf4d": "3.0.1",
|
||||||
|
|
|
@ -18,12 +18,22 @@ import core.sync.mutex;
|
||||||
import shared_utils.server_status;
|
import shared_utils.server_status;
|
||||||
import shared_utils.discord;
|
import shared_utils.discord;
|
||||||
|
|
||||||
|
const AGENT_KEY_HEADER = "X-Agent-Key";
|
||||||
|
const CLIENT_KEY_HEADER = "X-Client-Key";
|
||||||
|
|
||||||
__gshared ServerStatus[] serverStatuses;
|
__gshared ServerStatus[] serverStatuses;
|
||||||
__gshared Mutex serversMutex;
|
__gshared Mutex serversMutex;
|
||||||
__gshared string agentKey = "abc";
|
__gshared ApiConfig config;
|
||||||
__gshared string clientKey = "abc";
|
|
||||||
|
struct ApiConfig {
|
||||||
|
string agentKey;
|
||||||
|
string clientKey;
|
||||||
|
string discordWebhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
config = readConfig();
|
||||||
|
|
||||||
auto provider = new DefaultProvider(false, Levels.INFO);
|
auto provider = new DefaultProvider(false, Levels.INFO);
|
||||||
configureLoggingProvider(provider);
|
configureLoggingProvider(provider);
|
||||||
|
|
||||||
|
@ -39,22 +49,19 @@ void main() {
|
||||||
DirectoryResolutionStrategies.serveIndexFiles
|
DirectoryResolutionStrategies.serveIndexFiles
|
||||||
));
|
));
|
||||||
|
|
||||||
ServerConfig config;
|
ServerConfig serverConfig;
|
||||||
config.connectionQueueSize = 20;
|
serverConfig.connectionQueueSize = 20;
|
||||||
config.receiveBufferSize = 4096;
|
serverConfig.receiveBufferSize = 4096;
|
||||||
config.workerPoolSize = 3;
|
serverConfig.workerPoolSize = 3;
|
||||||
config.port = 8105;
|
serverConfig.port = 8105;
|
||||||
HttpServer server = new HttpServer(handler, config);
|
HttpServer server = new HttpServer(handler, serverConfig);
|
||||||
server.start();
|
server.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when the agent posts the server status to us.
|
/// Called when the agent posts the server status to us.
|
||||||
void postServerStatus(ref HttpRequestContext ctx) {
|
void postServerStatus(ref HttpRequestContext ctx) {
|
||||||
Optional!string key = ctx.request.headers.getFirst("X-Agent-Key");
|
checkKey(ctx, AGENT_KEY_HEADER, config.agentKey);
|
||||||
if (!key || key.value != agentKey) {
|
|
||||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
JSONValue jsonBody = ctx.request.readBodyAsJson();
|
JSONValue jsonBody = ctx.request.readBodyAsJson();
|
||||||
serversMutex.lock();
|
serversMutex.lock();
|
||||||
scope(exit) serversMutex.unlock();
|
scope(exit) serversMutex.unlock();
|
||||||
|
@ -78,11 +85,7 @@ void listServers(ref HttpRequestContext ctx) {
|
||||||
|
|
||||||
/// Called by a user when they request to start a server.
|
/// Called by a user when they request to start a server.
|
||||||
void requestServerStartup(ref HttpRequestContext ctx) {
|
void requestServerStartup(ref HttpRequestContext ctx) {
|
||||||
Optional!string key = ctx.request.headers.getFirst("X-Client-Key");
|
checkKey(ctx, CLIENT_KEY_HEADER, config.clientKey);
|
||||||
if (!key || key.value != clientKey) {
|
|
||||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string identifier = ctx.request.getPathParamAs!string("id");
|
string identifier = ctx.request.getPathParamAs!string("id");
|
||||||
serversMutex.lock();
|
serversMutex.lock();
|
||||||
|
@ -92,7 +95,10 @@ void requestServerStartup(ref HttpRequestContext ctx) {
|
||||||
File f = File("request_" ~ identifier ~ ".txt", "w");
|
File f = File("request_" ~ identifier ~ ".txt", "w");
|
||||||
f.writeln(Clock.currTime().toISOExtString());
|
f.writeln(Clock.currTime().toISOExtString());
|
||||||
f.close();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -115,3 +121,22 @@ void getServerRequests(ref HttpRequestContext ctx) {
|
||||||
bool isStartupRequested(string id) {
|
bool isStartupRequested(string id) {
|
||||||
return std.file.exists("request_" ~ id ~ ".txt");
|
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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
|
@ -1,15 +1,14 @@
|
||||||
module shared_utils.discord;
|
module shared_utils.discord;
|
||||||
|
|
||||||
void sendDiscordMessage(string msg) {
|
void sendDiscordMessage(string webhookUrl, string msg) {
|
||||||
import requests;
|
import requests;
|
||||||
import std.string : strip;
|
import std.string : strip;
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
import std.format;
|
import std.format;
|
||||||
const string WEBHOOK_URL = "https://discord.com/api/webhooks/1242607102011244645/fIBfGz3_Xp_C0EQTymXhcUW6kfde45mo01wvvJ9RerFfItTPX23eu5QUAdulaJBwaQrK";
|
|
||||||
string payload = "{\"content\": \"" ~ strip(msg) ~ "\"}";
|
string payload = "{\"content\": \"" ~ strip(msg) ~ "\"}";
|
||||||
try {
|
try {
|
||||||
Request rq = Request();
|
Request rq = Request();
|
||||||
Response resp = rq.post(WEBHOOK_URL, payload, "application/json");
|
Response resp = rq.post(webhookUrl, payload, "application/json");
|
||||||
if (resp.code >= 300) {
|
if (resp.code >= 300) {
|
||||||
writeln(resp.code);
|
writeln(resp.code);
|
||||||
writeln(resp.responseBody);
|
writeln(resp.responseBody);
|
||||||
|
|
|
@ -1,19 +1,37 @@
|
||||||
module shared_utils.server_status;
|
module shared_utils.server_status;
|
||||||
|
|
||||||
import std.json;
|
import std.json;
|
||||||
|
import std.algorithm;
|
||||||
|
import std.array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A struct containing basic information about a single Minecraft server.
|
||||||
|
*/
|
||||||
struct ServerStatus {
|
struct ServerStatus {
|
||||||
string identifier = null;
|
/// A unique identifier for the server, defined by the agent's "servers.json" file.
|
||||||
string name = null;
|
const string identifier = null;
|
||||||
bool online = false;
|
/// The human-readable name of the server.
|
||||||
int playersOnline = 0;
|
const string name = null;
|
||||||
int maxPlayers = 0;
|
/// A description for the server.
|
||||||
string[] playerNames = [];
|
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;
|
JSONValue obj = JSONValue.emptyObject;
|
||||||
obj.object["identifier"] = JSONValue(identifier);
|
obj.object["identifier"] = JSONValue(identifier);
|
||||||
obj.object["name"] = JSONValue(name);
|
obj.object["name"] = JSONValue(name);
|
||||||
|
obj.object["description"] = JSONValue(description);
|
||||||
obj.object["online"] = JSONValue(online);
|
obj.object["online"] = JSONValue(online);
|
||||||
obj.object["playersOnline"] = JSONValue(playersOnline);
|
obj.object["playersOnline"] = JSONValue(playersOnline);
|
||||||
obj.object["maxPlayers"] = JSONValue(maxPlayers);
|
obj.object["maxPlayers"] = JSONValue(maxPlayers);
|
||||||
|
@ -24,22 +42,43 @@ struct ServerStatus {
|
||||||
return obj;
|
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) {
|
static ServerStatus fromJsonObject(JSONValue obj) {
|
||||||
if (obj.type != JSONType.OBJECT) throw new JSONException("JSON value is not an object.");
|
if (obj.type != JSONType.OBJECT) throw new JSONException("JSON value is not an object.");
|
||||||
ServerStatus s;
|
return ServerStatus(
|
||||||
s.identifier = obj.object["identifier"].str;
|
obj.object["identifier"].str,
|
||||||
s.name = obj.object["name"].str;
|
obj.object["name"].str,
|
||||||
s.online = obj.object["online"].boolean;
|
obj.object["description"].str,
|
||||||
s.playersOnline = cast(int) obj.object["playersOnline"].integer;
|
obj.object["online"].boolean,
|
||||||
s.maxPlayers = cast(int) obj.object["maxPlayers"].integer;
|
cast(int) obj.object["playersOnline"].integer,
|
||||||
foreach (node; obj.object["playerNames"].array) {
|
cast(int) obj.object["maxPlayers"].integer,
|
||||||
s.playerNames ~= node.str;
|
obj.object["playerNames"].array().map!(node => node.str).array
|
||||||
}
|
);
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
JSONValue arr = JSONValue.emptyArray;
|
||||||
foreach (s; statuses) {
|
foreach (s; statuses) {
|
||||||
arr.array ~= s.toJsonObject();
|
arr.array ~= s.toJsonObject();
|
||||||
|
@ -47,11 +86,13 @@ JSONValue serializeServerStatuses(ServerStatus[] statuses) {
|
||||||
return arr;
|
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.");
|
if (arr.type != JSONType.ARRAY) throw new JSONException("JSON value is not an array.");
|
||||||
ServerStatus[] statuses = new ServerStatus[arr.array.length];
|
return arr.array().map!(obj => ServerStatus.fromJsonObject(obj)).array;
|
||||||
for (size_t i = 0; i < arr.array.length; i++) {
|
|
||||||
statuses[i] = ServerStatus.fromJsonObject(arr.array[i]);
|
|
||||||
}
|
|
||||||
return statuses;
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue