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