From 76641865599625d1191cdd05db025f0ef962a6d4 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 27 Jun 2024 11:58:40 -0400 Subject: [PATCH] Added agent, refactored API, working version now. --- README.md | 18 +- agent/dub.json | 17 ++ agent/dub.selections.json | 13 + agent/mc-agent.service | 8 + agent/mc-agent.timer | 9 + agent/servers.json | 18 ++ agent/source/app.d | 236 ++++++++++++++++++ api/app/index.html | 176 +++++++++++++ dub.json => api/dub.json | 6 +- api/dub.selections.json | 16 ++ api/mc-server-manager.service | 13 + api/source/app.d | 117 +++++++++ deploy-agent.sh | 6 + deploy-web.sh | 10 + dub.selections.json | 10 - shared-utils/.gitignore | 16 ++ shared-utils/dub.json | 13 + shared-utils/source/shared_utils/discord.d | 21 ++ shared-utils/source/shared_utils/package.d | 3 + .../source/shared_utils/server_protocol.d | 205 +++++++++++++++ .../source/shared_utils/server_status.d | 57 +++++ source/app.d | 76 ------ source/server_protocol.d | 117 --------- 23 files changed, 976 insertions(+), 205 deletions(-) create mode 100644 agent/dub.json create mode 100644 agent/dub.selections.json create mode 100644 agent/mc-agent.service create mode 100644 agent/mc-agent.timer create mode 100644 agent/servers.json create mode 100644 agent/source/app.d create mode 100644 api/app/index.html rename dub.json => api/dub.json (75%) create mode 100644 api/dub.selections.json create mode 100644 api/mc-server-manager.service create mode 100644 api/source/app.d create mode 100755 deploy-agent.sh create mode 100755 deploy-web.sh delete mode 100644 dub.selections.json create mode 100644 shared-utils/.gitignore create mode 100644 shared-utils/dub.json create mode 100644 shared-utils/source/shared_utils/discord.d create mode 100644 shared-utils/source/shared_utils/package.d create mode 100644 shared-utils/source/shared_utils/server_protocol.d create mode 100644 shared-utils/source/shared_utils/server_status.d delete mode 100644 source/app.d delete mode 100644 source/server_protocol.d 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. +

+
+ + +
+
+ + +
+ + +
+ + +

+ +
+ + + + + \ 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); -}