diff --git a/README.md b/README.md
index a129b2b..de6d0d8 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,19 @@
# mc-server-manager
-Simple web server where players can see a list of available servers, player counts, and request to turn on a server if they have a passcode.
\ No newline at end of file
+Simple web server where players can see a list of available servers, player
+counts, and request to turn on a server if they have a passcode.
+
+It consists of two components: a web server, and an agent program that runs on
+the minecraft server hardware itself.
+
+## Web Server
+
+The web server's responsibility is to show the latest server status information
+and provide a way for players to request to turn on a server.
+
+## 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.
+
diff --git a/agent/dub.json b/agent/dub.json
new file mode 100644
index 0000000..a14975e
--- /dev/null
+++ b/agent/dub.json
@@ -0,0 +1,17 @@
+{
+ "authors": [
+ "Andrew Lalis"
+ ],
+ "copyright": "Copyright © 2024, Andrew Lalis",
+ "dependencies": {
+ "mcrcd": "~>2.0.0",
+ "proper-d": "~>0.0.2",
+ "requests": "~>2.1.3",
+ "shared-utils": {
+ "path": "../shared-utils"
+ }
+ },
+ "description": "Scheduled job that checks for user requests for servers to start, and starts them.",
+ "license": "proprietary",
+ "name": "agent"
+}
\ No newline at end of file
diff --git a/agent/dub.selections.json b/agent/dub.selections.json
new file mode 100644
index 0000000..39c0937
--- /dev/null
+++ b/agent/dub.selections.json
@@ -0,0 +1,13 @@
+{
+ "fileVersion": 1,
+ "versions": {
+ "automem": "0.6.10",
+ "cachetools": "0.4.1",
+ "mcrcd": "2.0.0",
+ "proper-d": "0.0.2",
+ "requests": "2.1.3",
+ "shared-utils": {"path":"../shared-utils"},
+ "test_allocator": "0.3.4",
+ "unit-threaded": "0.10.8"
+ }
+}
diff --git a/agent/mc-agent.service b/agent/mc-agent.service
new file mode 100644
index 0000000..5b2c6ec
--- /dev/null
+++ b/agent/mc-agent.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=MC Server Agent
+
+[Service]
+Type=simple
+User=andrew
+WorkingDirectory=/home/andrew/minecraft/tools
+ExecStart=/home/andrew/minecraft/tools/agent
diff --git a/agent/mc-agent.timer b/agent/mc-agent.timer
new file mode 100644
index 0000000..1667cf5
--- /dev/null
+++ b/agent/mc-agent.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=Run MC Server Agent Periodically
+
+[Timer]
+OnBootSec=30
+OnUnitActiveSec=5
+AccuracySec=1s
+[Install]
+WantedBy=timers.target
diff --git a/agent/servers.json b/agent/servers.json
new file mode 100644
index 0000000..c716cda
--- /dev/null
+++ b/agent/servers.json
@@ -0,0 +1,18 @@
+[
+ {
+ "name": "graces-grove",
+ "directory": "graceworld-fabric"
+ },
+ {
+ "name": "klaus-paradise",
+ "directory": "klausparadise"
+ },
+ {
+ "name": "the-cave",
+ "directory": "the-cave"
+ },
+ {
+ "name": "modnet",
+ "directory": "modnet"
+ }
+]
\ No newline at end of file
diff --git a/agent/source/app.d b/agent/source/app.d
new file mode 100644
index 0000000..b50b065
--- /dev/null
+++ b/agent/source/app.d
@@ -0,0 +1,236 @@
+import std.stdio;
+import std.json;
+import std.algorithm;
+import std.string;
+import std.array;
+import std.process;
+import std.file;
+import std.path;
+import std.datetime;
+import std.conv;
+
+import requests;
+import mcrcd;
+
+import shared_utils.server_status;
+import shared_utils.discord;
+
+/**
+ * The set of information about each server that we can obtain by reading the
+ * server's directory and files in it (service file, properties, etc.).
+ */
+struct ServerInfo {
+ /// The name of the server, as declared in servers.json.
+ string name;
+ /// The directory to find the server's files in.
+ string directory;
+ /// The name of the server's SystemD service, as determined by searching the server's directory.
+ string serviceName;
+ ushort port;
+ string rconPassword;
+ ushort rconPort;
+ int maxPlayers;
+}
+
+struct AgentConfig {
+ string webUrl = "http://localhost:8080";
+ string agentKey = "abc";
+ int serverInactivityTimeoutMinutes = 30;
+}
+
+/**
+ * This program will run frequently (maybe every minute or more), and does the following:
+ * - Fetches a list of requested servers from the mc-server-manager API, and starts them.
+ * - Checks for any servers without any players online, and writes a blank file to track.
+ * - For servers with nobody online, and a timestamp file older than N minutes, the server is shut down.
+ */
+void main() {
+ AgentConfig config = readConfig();
+ ServerInfo[] servers = getServers();
+ ServerStatus[] statuses = servers.map!(getStatus).array;
+ try {
+ sendServerStatusToWeb(statuses, config);
+ } catch (Exception e) {
+ stderr.writeln(e.msg);
+ }
+ checkForEmptyServers(servers, statuses, config);
+ try {
+ startRequestedServers(servers, config);
+ } catch (Exception e) {
+ stderr.writeln(e.msg);
+ }
+}
+
+void sendServerStatusToWeb(ServerStatus[] statuses, AgentConfig config) {
+ import std.json;
+ import requests;
+ JSONValue jsonPayload = serializeServerStatuses(statuses);
+ string payload = jsonPayload.toJSON();
+ Request rq = Request();
+ rq.addHeaders(["X-Agent-Key": config.agentKey]);
+ Response resp = rq.post(config.webUrl ~ "/api/servers", payload, "application/json");
+ if (resp.code >= 300) {
+ import std.format;
+ throw new Exception(
+ format!"Failed to post server statuses: Error code %d, Response body: %s"(
+ resp.code,
+ resp.responseBody
+ )
+ );
+ }
+}
+
+void startRequestedServers(ServerInfo[] servers, AgentConfig config) {
+ auto content = getContent(config.webUrl ~ "/api/server-requests");
+ JSONValue jsonContent = parseJSON(cast(string) content.data);
+ string[] serverNames;
+ foreach (item; jsonContent.array) {
+ serverNames ~= item.str;
+ }
+
+ foreach (server; servers) {
+ foreach (serverName; serverNames) {
+ if (serverName == server.name) {
+ writeln("Starting server " ~ serverName);
+ Pid pid = spawnProcess(["sudo", "systemctl", "start", server.serviceName]);
+ int result = wait(pid);
+ if (result != 0) {
+ writefln!"Starting server %s failed with code %d"(serverName, result);
+ throw new Exception("Failed to start server.");
+ }
+ import std.format;
+ sendDiscordMessage(format!"Started server %s as a result of a user request."(serverName));
+ }
+ }
+ }
+}
+
+void checkForEmptyServers(ServerInfo[] servers, ServerStatus[] statuses, AgentConfig config) {
+ foreach (i, server; servers) {
+ ServerStatus status = statuses[i];
+ const string idleTrackerFile = "agent-idle-tracker__" ~ server.name ~ ".txt";
+ if (std.file.exists(idleTrackerFile)) {
+ // Tracker file already exists.
+ if (status.online && status.playersOnline > 0) {
+ // Players are active, remove the tracker file.
+ std.file.remove(idleTrackerFile);
+ writeln("Removed idle tracker for server " ~ server.name);
+ } else if (status.online && status.playersOnline == 0) {
+ // No players are active, check if trackerfile is older than N minutes.
+ SysTime timestamp = std.file.timeLastModified(idleTrackerFile);
+ Duration dur = Clock.currTime - timestamp;
+ writefln!"Server %s has been idle for at least %d minutes"(server.name, dur.total!"minutes");
+ if (dur.total!"minutes" > config.serverInactivityTimeoutMinutes) {
+ // Inactivity for too long, so shut down the server.
+ writeln("Shutting down server " ~ server.name ~ " after a period of inactivity.");
+ import std.process;
+ Pid pid = spawnProcess(["sudo", "systemctl", "stop", server.serviceName]);
+ int result = wait(pid);
+ if (result == 0) {
+ std.file.remove(idleTrackerFile);
+ import std.format;
+ sendDiscordMessage(format!"Shut down server %s after inactivity for more than %d minutes."(
+ server.name,
+ config.serverInactivityTimeoutMinutes
+ ));
+ } else {
+ stderr.writefln!"Failed to stop server %s. systemctl stop exited with code %d."(server.name, result);
+ }
+ }
+ }
+ } else if (status.online && status.playersOnline == 0) {
+ // Create new tracker file.
+ File f = File(idleTrackerFile, "w");
+ f.close();
+ writeln("Created idle tracker for server " ~ server.name ~ " because nobody is online.");
+ }
+ }
+}
+
+ServerInfo[] getServers() {
+ import std.json;
+ import properd;
+
+ JSONValue arr = parseJSON(readText("servers.json"));
+ return arr.array.map!((s) {
+ ServerInfo info;
+ info.name = s.object["name"].str;
+ info.directory = s.object["directory"].str;
+ string propsFile = buildPath(info.directory, "server.properties");
+ if (!exists(propsFile)) throw new Exception("Missing server properties file: " ~ propsFile);
+ try {
+ auto props = readProperties(propsFile);
+ info.port = props.as!(ushort)("server-port");
+ info.rconPassword = props["rcon.password"];
+ info.rconPort = props.as!(ushort)("rcon.port");
+ info.maxPlayers = props.as!(int)("max-players");
+ } catch (PropertyException e) {
+ stderr.writefln!"Error parsing properties from %s: %s"(propsFile, e.msg);
+ throw e;
+ }
+
+ info.serviceName = null;
+ foreach (DirEntry entry; dirEntries(info.directory, SpanMode.shallow, false)) {
+ if (entry.name.endsWith(".service")) {
+ info.serviceName = baseName(stripExtension(entry.name));
+ break;
+ }
+ }
+ if (info.serviceName is null) throw new Exception("No SystemD service file found in " ~ info.directory);
+
+ return info;
+ }).array;
+}
+
+ServerStatus getStatus(ServerInfo server) {
+ ServerStatus status;
+ status.identifier = server.name;
+ status.name = server.name; // TODO: Add display name to servers.json.
+ status.online = isServiceActive(server.serviceName);
+ status.maxPlayers = server.maxPlayers;
+ if (status.online) {
+ MCRconResponse response = executeRconCommand(server, "list");
+ string playersList;
+ int tmp;
+ import std.format;
+ response.text.formattedRead!"There are %d of a max of %d players online: %s"(
+ status.playersOnline,
+ tmp,
+ playersList
+ );
+ status.playerNames = playersList.strip.split(",")
+ .filter!(s => s !is null && s.strip.length > 0)
+ .map!(s => s.strip)
+ .array;
+ }
+ return status;
+}
+
+bool isServiceActive(string serviceName) {
+ import std.process;
+ Pid pid = spawnProcess(["systemctl", "is-active", "--quiet", serviceName]);
+ int result = wait(pid);
+ return result == 0;
+}
+
+MCRconResponse executeRconCommand(ServerInfo server, string command) {
+ MCRcon rcon = new MCRcon();
+ rcon.connect("127.0.0.1", server.rconPort);
+ scope(exit) {
+ rcon.disconnect();
+ }
+ rcon.login(server.rconPassword);
+ return rcon.command(command);
+}
+
+AgentConfig readConfig() {
+ import properd;
+ AgentConfig config;
+ if (std.file.exists("agent-config.properties")) {
+ auto props = readProperties("agent-config.properties");
+ config.webUrl = props["webUrl"];
+ config.agentKey = props["agentKey"];
+ config.serverInactivityTimeoutMinutes = props.as!(int)("serverInactivityTimeoutMinutes");
+ }
+ return config;
+}
diff --git a/api/app/index.html b/api/app/index.html
new file mode 100644
index 0000000..46526b8
--- /dev/null
+++ b/api/app/index.html
@@ -0,0 +1,176 @@
+
+
+
+
+ MC-Server-Manager
+
+
+
+
+ MC-Server-Manager
+
+ On this site, you'll see a list of available servers. If you've got the
+ correct credentials, you may request to turn on a server if it has
+ automatically shut off due to inactivity.
+
+
+
+
+
+
+
+
+ Request to start the selected server.
+
+
+ Enter your client key:
+
+
+
+ Server Name
+
+
+ Submit
+ Cancel
+
+
+
+
+ Close
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dub.json b/api/dub.json
similarity index 75%
rename from dub.json
rename to api/dub.json
index 55d3db0..193dace 100644
--- a/dub.json
+++ b/api/dub.json
@@ -4,7 +4,11 @@
],
"copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": {
- "handy-httpd": "~>8.4.0"
+ "handy-httpd": "~>8.4.0",
+ "requests": "~>2.1.3",
+ "shared-utils": {
+ "path": "../shared-utils"
+ }
},
"description": "Simple web server where players can see a list of available servers, player counts, and request to turn on a server if they have a passcode.",
"license": "proprietary",
diff --git a/api/dub.selections.json b/api/dub.selections.json
new file mode 100644
index 0000000..6853125
--- /dev/null
+++ b/api/dub.selections.json
@@ -0,0 +1,16 @@
+{
+ "fileVersion": 1,
+ "versions": {
+ "automem": "0.6.10",
+ "cachetools": "0.4.1",
+ "handy-httpd": "8.4.0",
+ "httparsed": "1.2.1",
+ "path-matcher": "1.2.0",
+ "requests": "2.1.3",
+ "shared-utils": {"path":"../shared-utils"},
+ "slf4d": "3.0.1",
+ "streams": "3.5.0",
+ "test_allocator": "0.3.4",
+ "unit-threaded": "0.10.8"
+ }
+}
diff --git a/api/mc-server-manager.service b/api/mc-server-manager.service
new file mode 100644
index 0000000..1cc9137
--- /dev/null
+++ b/api/mc-server-manager.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=mc-server-manager
+After=network.target
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=/opt/mc-server-manager
+ExecStart=/opt/mc-server-manager/mc-server-manager
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/api/source/app.d b/api/source/app.d
new file mode 100644
index 0000000..c4edd93
--- /dev/null
+++ b/api/source/app.d
@@ -0,0 +1,117 @@
+import handy_httpd;
+import handy_httpd.handlers.path_handler;
+import handy_httpd.handlers.file_resolving_handler;
+import handy_httpd.components.optional;
+import slf4d;
+import slf4d.default_provider;
+
+import std.json;
+import std.stdio;
+import std.file;
+import std.algorithm;
+import std.string;
+import std.array;
+import std.format;
+import std.datetime;
+import core.sync.mutex;
+
+import shared_utils.server_status;
+import shared_utils.discord;
+
+__gshared ServerStatus[] serverStatuses;
+__gshared Mutex serversMutex;
+__gshared string agentKey = "abc";
+__gshared string clientKey = "abc";
+
+void main() {
+ auto provider = new DefaultProvider(false, Levels.INFO);
+ configureLoggingProvider(provider);
+
+ serversMutex = new Mutex();
+
+ PathHandler handler = new PathHandler();
+ handler.addMapping(Method.POST, "/api/servers", &postServerStatus);
+ handler.addMapping(Method.GET, "/api/servers", &listServers);
+ handler.addMapping(Method.POST, "/api/servers/:id/requests", &requestServerStartup);
+ handler.addMapping(Method.GET, "/api/server-requests", &getServerRequests);
+ handler.addMapping(Method.GET, "/**", new FileResolvingHandler(
+ "app",
+ DirectoryResolutionStrategies.serveIndexFiles
+ ));
+
+ ServerConfig config;
+ config.connectionQueueSize = 20;
+ config.receiveBufferSize = 4096;
+ config.workerPoolSize = 3;
+ config.port = 8105;
+ HttpServer server = new HttpServer(handler, config);
+ 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;
+ }
+ JSONValue jsonBody = ctx.request.readBodyAsJson();
+ serversMutex.lock();
+ scope(exit) serversMutex.unlock();
+ serverStatuses = deserializeServerStatuses(jsonBody);
+
+ // Remove startup requests for any servers that are now online.
+ foreach (server; serverStatuses) {
+ if (server.online && isStartupRequested(server.identifier)) {
+ std.file.remove("request_" ~ server.identifier ~ ".txt");
+ }
+ }
+}
+
+/// Called by the web app when a user is refreshing the list of servers.
+void listServers(ref HttpRequestContext ctx) {
+ serversMutex.lock();
+ scope(exit) serversMutex.unlock();
+ JSONValue payload = serializeServerStatuses(serverStatuses);
+ ctx.response.writeBodyString(payload.toJSON, "application/json");
+}
+
+/// 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;
+ }
+
+ string identifier = ctx.request.getPathParamAs!string("id");
+ serversMutex.lock();
+ scope(exit) serversMutex.unlock();
+ foreach (server; serverStatuses) {
+ if (server.identifier == identifier) {
+ File f = File("request_" ~ identifier ~ ".txt", "w");
+ f.writeln(Clock.currTime().toISOExtString());
+ f.close();
+ sendDiscordMessage(format!"User requested to start server %s."(server.identifier));
+ return;
+ }
+ }
+ ctx.response.status = HttpStatus.NOT_FOUND;
+}
+
+/// Called by the agent to get the list of servers to start.
+void getServerRequests(ref HttpRequestContext ctx) {
+ JSONValue result = JSONValue.emptyArray;
+ serversMutex.lock();
+ scope(exit) serversMutex.unlock();
+ foreach (server; serverStatuses) {
+ if (isStartupRequested(server.identifier)) {
+ result.array ~= JSONValue(server.identifier);
+ }
+ }
+ ctx.response.writeBodyString(result.toJSON, "application/json");
+}
+
+bool isStartupRequested(string id) {
+ return std.file.exists("request_" ~ id ~ ".txt");
+}
diff --git a/deploy-agent.sh b/deploy-agent.sh
new file mode 100755
index 0000000..d20c4ea
--- /dev/null
+++ b/deploy-agent.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+addr_mc_server=andrew@192.168.0.20
+dub --root=agent --quiet clean
+dub --root=agent --quiet build
+rsync agent/agent $addr_mc_server:minecraft/tools/agent
diff --git a/deploy-web.sh b/deploy-web.sh
new file mode 100755
index 0000000..7961901
--- /dev/null
+++ b/deploy-web.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+echo "Building API"
+dub --root=api --quiet clean
+dub --root=api --quiet build --build=release
+
+ssh -f root@andrewlalis.com 'systemctl stop mc-server-manager.service'
+rsync api/mc-server-manager root@andrewlalis.com:/opt/mc-server-manager/mc-server-manager
+rsync -rav -e ssh --delete api/app/* root@andrewlalis.com:/opt/mc-server-manager/app/
+ssh -f root@andrewlalis.com 'systemctl start mc-server-manager.service'
diff --git a/dub.selections.json b/dub.selections.json
deleted file mode 100644
index 07cab5f..0000000
--- a/dub.selections.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "fileVersion": 1,
- "versions": {
- "handy-httpd": "8.4.0",
- "httparsed": "1.2.1",
- "path-matcher": "1.2.0",
- "slf4d": "3.0.1",
- "streams": "3.5.0"
- }
-}
diff --git a/shared-utils/.gitignore b/shared-utils/.gitignore
new file mode 100644
index 0000000..841b8cf
--- /dev/null
+++ b/shared-utils/.gitignore
@@ -0,0 +1,16 @@
+.dub
+docs.json
+__dummy.html
+docs/
+/shared-utils
+shared-utils.so
+shared-utils.dylib
+shared-utils.dll
+shared-utils.a
+shared-utils.lib
+shared-utils-test-*
+*.exe
+*.pdb
+*.o
+*.obj
+*.lst
diff --git a/shared-utils/dub.json b/shared-utils/dub.json
new file mode 100644
index 0000000..5db5690
--- /dev/null
+++ b/shared-utils/dub.json
@@ -0,0 +1,13 @@
+{
+ "authors": [
+ "Andrew Lalis"
+ ],
+ "copyright": "Copyright © 2024, Andrew Lalis",
+ "dependencies": {
+ "requests": "~>2.1.3"
+ },
+ "description": "Shared code for both agent and API.",
+ "license": "proprietary",
+ "name": "shared-utils",
+ "targetType": "sourceLibrary"
+}
\ No newline at end of file
diff --git a/shared-utils/source/shared_utils/discord.d b/shared-utils/source/shared_utils/discord.d
new file mode 100644
index 0000000..d67d569
--- /dev/null
+++ b/shared-utils/source/shared_utils/discord.d
@@ -0,0 +1,21 @@
+module shared_utils.discord;
+
+void sendDiscordMessage(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");
+ if (resp.code >= 300) {
+ writeln(resp.code);
+ writeln(resp.responseBody);
+ throw new Exception(format!"Discord message failed with code %d: %s"(resp.code, resp.responseBody));
+ }
+ } catch (Exception e) {
+ stderr.writefln("Failed to send discord webhook message: ", e);
+ }
+}
\ No newline at end of file
diff --git a/shared-utils/source/shared_utils/package.d b/shared-utils/source/shared_utils/package.d
new file mode 100644
index 0000000..4fa8741
--- /dev/null
+++ b/shared-utils/source/shared_utils/package.d
@@ -0,0 +1,3 @@
+module shared_utils;
+
+public import shared_utils.server_status;
diff --git a/shared-utils/source/shared_utils/server_protocol.d b/shared-utils/source/shared_utils/server_protocol.d
new file mode 100644
index 0000000..ced3262
--- /dev/null
+++ b/shared-utils/source/shared_utils/server_protocol.d
@@ -0,0 +1,205 @@
+module shared_utils.server_protocol;
+
+import std.socket;
+import std.algorithm;
+import std.conv;
+import std.array;
+import std.range;
+import std.bitmanip;
+import std.json;
+import std.stdio;
+import std.datetime;
+
+const ubyte SEGMENT_BITS = 0x7F;
+const ubyte CONTINUE_BIT = 0x80;
+
+/**
+ * The data describing a minecraft server's status.
+ */
+struct ServerStatus {
+ string name;
+ bool online;
+ int playersOnline;
+ int maxPlayers;
+ string[] playerNames;
+ bool startupRequested = false;
+
+ JSONValue toJsonObject() {
+ JSONValue obj = JSONValue.emptyObject;
+ obj.object["name"] = JSONValue(name);
+ obj.object["online"] = JSONValue(online);
+ obj.object["playersOnline"] = JSONValue(playersOnline);
+ obj.object["maxPlayers"] = JSONValue(maxPlayers);
+ obj.object["playerNames"] = JSONValue.emptyArray;
+ foreach (name; playerNames) {
+ obj.object["playerNames"].array ~= JSONValue(name);
+ }
+ obj.object["startupRequested"] = JSONValue(startupRequested);
+ return obj;
+ }
+}
+
+/**
+ * Attempts to fetch the current server status by connecting to the server
+ * with a status request packet. In the case of communication errors, the
+ * server will be reported as offline.
+ * Params:
+ * name = The name of the server (only used to add to the ServerStatus struct).
+ * ipAndPort = The combined IP:PORT string used to connect to the server.
+ * Returns: The server status.
+ */
+ServerStatus fetchStatus(string name, string ipAndPort) {
+ ptrdiff_t portIdx = countUntil(ipAndPort, ":");
+ if (portIdx == -1) throw new Exception("Invalid IP address. Port is required.");
+ string ip = ipAndPort[0..portIdx];
+ ushort port = ipAndPort[portIdx + 1 .. $].to!ushort;
+ writeln("Got IP " ~ ip ~ " and port ", port);
+ Address address = new InternetAddress(ip, port);
+ writeln("Got address: ", address);
+
+ Socket socket = new TcpSocket();
+ try {
+ writeln("Socket created.");
+ socket.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, seconds(5));
+ socket.setOption(SocketOptionLevel.SOCKET, SocketOption.SNDTIMEO, seconds(5));
+ socket.connect(address);
+ writeln("opened socket");
+ } catch (SocketException e) {
+ writeln(e);
+ return ServerStatus(name, false, 0, -1, []);
+ }
+ writeln("About to send handshake packet...");
+ socket.send(getHandshakePacket(763, ip, port, 1));
+ writeln("About to send status packet...");
+ socket.send(getStatusPacket());
+ writeln("Sent status packet request.");
+
+ ResponsePacket packet = readResponse(socket);
+ writeln("Received packet response", packet.data.length);
+ JSONValue statusJson = readStatusResponse(packet.data);
+ return ServerStatus(
+ name,
+ true,
+ cast(int) statusJson.object["players"].object["online"].integer,
+ cast(int) statusJson.object["players"].object["max"].integer,
+ []
+ );
+}
+
+private struct ResponsePacket {
+ int packetId;
+ ubyte[] data;
+}
+
+private ResponsePacket readResponse(Socket socket) {
+ ubyte[4096] buffer;
+ ptrdiff_t initialBytesReceived = socket.receive(buffer);
+ if (initialBytesReceived == Socket.ERROR || initialBytesReceived == 0) {
+ throw new Exception("Couldn't read initial server response.");
+ }
+ ubyte[] bytes = buffer[0..initialBytesReceived].dup;
+ int length = readVarInt(bytes);
+ while (bytes.length < length) {
+ ptrdiff_t bytesReceived = socket.receive(buffer);
+ if (bytesReceived == Socket.ERROR || bytesReceived == 0) {
+ throw new Exception("Failed to read more data from server.");
+ }
+ bytes ~= buffer[0..bytesReceived].dup;
+ }
+
+ ResponsePacket packet;
+ packet.packetId = readVarInt(bytes);
+ packet.data = bytes;
+ return packet;
+}
+
+private ubyte[] getHandshakePacket(int protocolVersion, string ip, ushort port, int nextState) {
+ Appender!(ubyte[]) dataApp;
+ writeVarInt(dataApp, 0x00);
+ writeVarInt(dataApp, protocolVersion);
+ writeString(dataApp, ip);
+ ubyte[2] shortData = nativeToBigEndian(port);
+ dataApp ~= shortData[0];
+ dataApp ~= shortData[1];
+ writeVarInt(dataApp, nextState);
+ size_t packetLength = dataApp[].length;
+ Appender!(ubyte[]) packetLengthApp;
+ writeVarInt(packetLengthApp, cast(int) packetLength);
+ return packetLengthApp[] ~ dataApp[];
+}
+
+private ubyte[] getStatusPacket() {
+ return [1, 0];
+}
+
+private JSONValue readStatusResponse(R)(ref R r) if (isInputRange!R && is(ElementType!R == ubyte)) {
+ string jsonStr = readString(r);
+ return parseJSON(jsonStr);
+}
+
+private int readVarInt(R)(ref R r) if (isInputRange!R && is(ElementType!R == ubyte)) {
+ int value = 0;
+ int position = 0;
+ ubyte currentByte;
+ while (true) {
+ currentByte = r.front();
+ r.popFront();
+ value |= (currentByte & SEGMENT_BITS) << position;
+ if ((currentByte & CONTINUE_BIT) == 0) break;
+ position += 7;
+ if (position >= 32) throw new Exception("VarInt is too big");
+ }
+ return value;
+}
+
+private string readString(R)(ref R r) if (isInputRange!R && is(ElementType!R == ubyte)) {
+ int length = readVarInt(r);
+ Appender!(ubyte[]) app;
+ for (size_t i = 0; i < length; i++) {
+ if (r.empty()) {
+ writefln!"Range empty after %d instead of expected %d."(i, length);
+ break;
+ }
+ app ~= r.front();
+ r.popFront();
+ }
+ return cast(string) app[];
+}
+
+private void writeVarInt(R)(ref R r, int value) if (isOutputRange!(R, ubyte)) {
+ while (true) {
+ if ((value & ~SEGMENT_BITS) == 0) {
+ r.put(cast(ubyte) value);
+ return;
+ }
+ r.put(cast(ubyte) ((value & SEGMENT_BITS) | CONTINUE_BIT));
+ value >>>= 7;
+ }
+}
+
+private void writeString(R)(ref R r, string s) if (isOutputRange!(R, ubyte)) {
+ writeVarInt(r, cast(int) s.length);
+ ubyte[] bytes = cast(ubyte[]) s;
+ foreach (b; bytes) r.put(b);
+}
+
+unittest {
+ void testReadWriteInt(int value, ubyte[] bytes) {
+ Appender!(ubyte[]) app;
+ writeVarInt(app, value);
+ ubyte[] data = app[];
+ assert(data == bytes);
+ int readValue = readVarInt(data);
+ assert(readValue == value);
+ }
+
+ testReadWriteInt(0, [0]);
+ testReadWriteInt(1, [1]);
+ testReadWriteInt(2, [2]);
+ testReadWriteInt(127, [127]);
+ testReadWriteInt(128, [128, 1]);
+ testReadWriteInt(255, [255, 1]);
+ testReadWriteInt(25_565, [221, 199, 1]);
+ testReadWriteInt(5181, [189, 40]);
+ testReadWriteInt(5178, [186, 40]);
+}
diff --git a/shared-utils/source/shared_utils/server_status.d b/shared-utils/source/shared_utils/server_status.d
new file mode 100644
index 0000000..9cc0b7c
--- /dev/null
+++ b/shared-utils/source/shared_utils/server_status.d
@@ -0,0 +1,57 @@
+module shared_utils.server_status;
+
+import std.json;
+
+struct ServerStatus {
+ string identifier = null;
+ string name = null;
+ bool online = false;
+ int playersOnline = 0;
+ int maxPlayers = 0;
+ string[] playerNames = [];
+
+ JSONValue toJsonObject() {
+ JSONValue obj = JSONValue.emptyObject;
+ obj.object["identifier"] = JSONValue(identifier);
+ obj.object["name"] = JSONValue(name);
+ obj.object["online"] = JSONValue(online);
+ obj.object["playersOnline"] = JSONValue(playersOnline);
+ obj.object["maxPlayers"] = JSONValue(maxPlayers);
+ obj.object["playerNames"] = JSONValue.emptyArray;
+ foreach (playerName; playerNames) {
+ obj.object["playerNames"].array ~= JSONValue(playerName);
+ }
+ return obj;
+ }
+
+ 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;
+ }
+}
+
+JSONValue serializeServerStatuses(ServerStatus[] statuses) {
+ JSONValue arr = JSONValue.emptyArray;
+ foreach (s; statuses) {
+ arr.array ~= s.toJsonObject();
+ }
+ return arr;
+}
+
+ServerStatus[] deserializeServerStatuses(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;
+}
diff --git a/source/app.d b/source/app.d
deleted file mode 100644
index 39195d3..0000000
--- a/source/app.d
+++ /dev/null
@@ -1,76 +0,0 @@
-import handy_httpd;
-import handy_httpd.handlers.path_handler;
-import handy_httpd.components.optional;
-
-import std.json;
-import std.stdio;
-import core.sync.mutex;
-
-import server_protocol;
-
-__gshared JSONValue latestServerData = JSONValue.emptyArray;
-__gshared Mutex dataMutex;
-__gshared string clientKey;
-__gshared string serverKey;
-
-void main(string[] args) {
- clientKey = args[1];
- serverKey = args[2];
-
- dataMutex = new Mutex();
-
- PathHandler handler = new PathHandler();
- handler.addMapping(Method.GET, "/servers", &listServers);
- handler.addMapping(Method.POST, "/servers", &postServerStatus);
- handler.addMapping(Method.POST, "/servers/:name/requests", &requestServerStartup);
-
- ServerConfig config;
- config.connectionQueueSize = 20;
- config.receiveBufferSize = 4096;
- config.workerPoolSize = 3;
- HttpServer server = new HttpServer(handler, config);
- server.start();
-}
-
-void listServers(ref HttpRequestContext ctx) {
- dataMutex.lock();
- ctx.response.writeBodyString(latestServerData.toJSON(), "application/json");
- dataMutex.unlock();
-}
-
-void postServerStatus(ref HttpRequestContext ctx) {
- Optional!string key = ctx.request.headers.getFirst("X-Server-Key");
- if (!key || key.value != serverKey) {
- ctx.response.status = HttpStatus.UNAUTHORIZED;
- return;
- }
-
- JSONValue data = ctx.request.readBodyAsJson();
- dataMutex.lock();
- latestServerData = data;
- writeln("Set server status to ", data.toJSON());
- dataMutex.unlock();
-}
-
-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;
- }
-
- string serverName = ctx.request.getPathParamAs!string("name");
- writeln(serverName);
- dataMutex.lock();
- scope(exit) {
- dataMutex.unlock();
- }
- foreach (JSONValue serverNode; latestServerData.array) {
- if (serverNode.object["name"].str == serverName) {
- writeln("Found match!");
- serverNode.object["requested"] = JSONValue(true);
- return;
- }
- }
- ctx.response.status = HttpStatus.NOT_FOUND;
-}
diff --git a/source/server_protocol.d b/source/server_protocol.d
deleted file mode 100644
index b9507bc..0000000
--- a/source/server_protocol.d
+++ /dev/null
@@ -1,117 +0,0 @@
-module server_protocol;
-
-import std.socket;
-import std.algorithm;
-import std.conv;
-import std.stdio;
-
-import streams;
-
-const int SEGMENT_BITS = 0x7F;
-const int CONTINUE_BIT = 0x80;
-
-struct ServerStatus {
- bool online;
- int playersOnline;
- int maxPlayers;
- string[] playerNames;
-}
-
-ServerStatus fetchStatus(string ipAndPort) {
- ptrdiff_t portIdx = countUntil(ipAndPort, ":");
- if (portIdx == -1) throw new Exception("Invalid IP address. Port is required.");
- string ip = ipAndPort[0..portIdx];
- ushort port = ipAndPort[portIdx + 1 .. $].to!ushort;
- Address address = new InternetAddress(ip, port);
- writeln(address);
-
- Socket socket = new TcpSocket(address);
- auto sIn = SocketInputStream(socket);
- auto sOut = SocketOutputStream(socket);
- auto bufferedOut = bufferedOutputStreamFor(sOut);
-
- auto arrayOut = byteArrayOutputStream();
-
- writeVarInt(&arrayOut, 0x00);
- writeVarInt(&arrayOut, 763);
- writeString(&arrayOut, ip);
- auto dOut = dataOutputStreamFor(&arrayOut, Endianness.BigEndian);
- dOut.writeToStream(port);
- writeVarInt(&arrayOut, 1);
-
- ubyte[] handshakePacket = arrayOut.toArray();
- writeln(handshakePacket);
- writeVarInt(&bufferedOut, cast(int) handshakePacket.length);
- bufferedOut.writeToStream(handshakePacket);
- bufferedOut.flushStream();
- writeln("Sent handshake packet.");
-
- auto arrayOut2 = byteArrayOutputStream();
- writeVarInt(arrayOut2, 0x00);
- ubyte[] statusRequestPacket = arrayOut2.toArray();
- writeVarInt(bufferedOut, cast(int) statusRequestPacket.length);
- bufferedOut.writeToStream(statusRequestPacket);
- bufferedOut.flushStream();
- writeln("Sent status request packet.");
-
- int responsePacketSize = readVarInt(sIn);
- writefln!"Got response of %d bytes"(responsePacketSize);
- ubyte[] packetIdAndData = new ubyte[responsePacketSize];
- StreamResult result = sIn.readFromStream(packetIdAndData);
- if (result.hasError || result.count != responsePacketSize) throw new Exception("Failed to read response packet.");
- auto packetIn = arrayInputStreamFor(packetIdAndData);
- int packetId = readVarInt(packetIn);
- if (packetId != 0x00) throw new Exception("Received invalid packetId when receiving status response.");
- string jsonStr = readString(packetIn);
- writeln(jsonStr);
-
-
- ServerStatus status;
- return status;
-}
-
-int readVarInt(S)(S s) if (isByteInputStream!S) {
- int value = 0;
- int position = 0;
- ubyte[1] buf;
- while (true) {
- writeln("Attempting to read from stream...");
-
- StreamResult result = s.readFromStream(buf);
- writeln(result);
- if (result.hasError) throw new Exception(cast(string) result.error.message);
- ubyte currentByte = buf[0];
- value |= (currentByte & SEGMENT_BITS) << position;
- if ((currentByte & CONTINUE_BIT) == 0) break;
- position += 7;
- if (position >= 32) throw new Exception("VarInt is too big.");
- }
- return value;
-}
-
-void writeVarInt(S)(S s, int value) if (isByteOutputStream!S) {
- while (true) {
- if ((value & ~SEGMENT_BITS) == 0) {
- StreamResult r = s.writeToStream([cast(ubyte) value]);
- if (r.hasError || r.count != 1) throw new Exception("Failed to write byte to stream");
- return;
- }
- StreamResult r = s.writeToStream([(value & SEGMENT_BITS) | CONTINUE_BIT]);
- if (r.hasError || r.count != 1) throw new Exception("Failed to write byte to stream");
- value >>>= 7;
- }
-}
-
-string readString(S)(S s) if (isByteInputStream!S) {
- int length = readVarInt(s);
- ubyte[] data = new ubyte[length];
- StreamResult result = s.readFromStream(data);
- if (result.hasError || result.count != length) throw new Exception("Couldn't read string.");
- return cast(string) data.idup;
-}
-
-void writeString(S)(S s, string str) if (isByteOutputStream!S) {
- ubyte[] bytes = cast(ubyte[]) str;
- writeVarInt(s, cast(int) bytes.length);
- s.writeToStream(bytes);
-}