Improved security by removing hard-coded keys and refactoring agent logic.

This commit is contained in:
Andrew Lalis 2024-08-03 14:15:41 -04:00
parent 807b47137d
commit 82bfb474d8
13 changed files with 423 additions and 233 deletions

View File

@ -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."
}
]
```

2
agent/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
agent
agent-test-library

View File

@ -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 (status.online && status.playersOnline > 0) {
if (std.file.exists(idleTrackerFile)) { removeIdleTrackerFileIfPresent(server);
// 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) { } else if (status.online && status.playersOnline == 0) {
// Create new tracker file. const Duration idleTime = getOrCreateIdleTrackerFileAndGetAge(server);
File f = File(idleTrackerFile, "w"); if (idleTime.total!"minutes" > config.serverInactivityTimeoutMinutes) {
f.close(); stopServer(server, config);
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;
} }

24
agent/source/config.d Normal file
View File

@ -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");
}

View File

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

View File

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

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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"]
);
}

3
shared-utils/README.md Normal file
View File

@ -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.

View File

@ -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);

View File

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