Added agent, refactored API, working version now.

This commit is contained in:
Andrew Lalis 2024-06-27 11:58:40 -04:00
parent 15a7adff7e
commit 7664186559
23 changed files with 976 additions and 205 deletions

View File

@ -1,3 +1,19 @@
# mc-server-manager # 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. 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.

17
agent/dub.json Normal file
View File

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

13
agent/dub.selections.json Normal file
View File

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

8
agent/mc-agent.service Normal file
View File

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

9
agent/mc-agent.timer Normal file
View File

@ -0,0 +1,9 @@
[Unit]
Description=Run MC Server Agent Periodically
[Timer]
OnBootSec=30
OnUnitActiveSec=5
AccuracySec=1s
[Install]
WantedBy=timers.target

18
agent/servers.json Normal file
View File

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

236
agent/source/app.d Normal file
View File

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

176
api/app/index.html Normal file
View File

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>MC-Server-Manager</title>
<style>
.server-listing {
border: 3px solid black;
padding: 0.5em;
}
.server-listing + .server-listing {
margin-top: 10px;
}
.server-listing h3 {
margin: 0 0 0.5em 0;
}
.server-listing p {
margin: 0.5em 0 0.5em 0;
}
</style>
</head>
<body>
<h1>MC-Server-Manager</h1>
<p>
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.
</p>
<hr>
<div id="servers-container">
<!-- This container will be populated with the list of available servers. -->
</div>
<dialog id="request-dialog">
<p>
Request to start the selected server.
</p>
<div style="margin-bottom: 0.5em">
<label for="client-key-input">Enter your client key:</label>
<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/>
</div>
<button onclick="submitServerRequest()">Submit</button>
<button onclick="closeRequestDialog()">Cancel</button>
</dialog>
<dialog id="message-dialog">
<p></p>
<button onclick="document.getElementById('message-dialog').close()">Close</button>
</dialog>
<script>
async function getServers() {
const response = await fetch("/api/servers");
return await response.json();
}
function makeServerListingDiv(serverObj) {
const div = document.createElement("div");
div.className = "server-listing";
const header = document.createElement("h3");
header.innerText = serverObj.name;
div.appendChild(header);
const playersList = document.createElement("p");
let text = "Players: ";
if (serverObj.playerNames.length == 0) {
text += "None";
} else {
for (let i = 0; i < serverObj.playerNames.length; i++) {
text += serverObj.playerNames[i];
if (i + 1 < serverObj.playerNames.length) {
text += ", ";
}
}
}
playersList.innerText = text;
div.appendChild(playersList);
const onlineIndicator = document.createElement("p");
onlineIndicator.innerText = serverObj.online ? "Online" : "Offline";
if (serverObj.online) {
onlineIndicator.style = "color: green";
} else {
onlineIndicator.style = "color: red";
}
div.appendChild(onlineIndicator);
if (serverObj.startupRequested) {
const startupRequestedIndicator = document.createElement("p");
startupRequestedIndicator.innerText = "Startup has been requested. Server should start shortly.";
startupRequestedIndicator.style = "font-style: italic";
div.appendChild(startupRequestedIndicator);
}
if (!serverObj.online && !serverObj.startupRequested) {
const requestButton = document.createElement("button");
requestButton.innerText = "Request to start this server";
requestButton.addEventListener("click", () => openRequestDialog(serverObj.name));
div.appendChild(requestButton);
}
return div;
}
function openRequestDialog(serverName) {
const serverNameInput = document.getElementById("server-name-input");
serverNameInput.value = serverName;
const dialog = document.getElementById("request-dialog");
dialog.showModal();
}
function closeRequestDialog() {
const dialog = document.getElementById("request-dialog");
dialog.close();
const clientKeyInput = document.getElementById("client-key-input");
clientKeyInput.value = "";
}
async function submitServerRequest() {
const serverName = document.getElementById("server-name-input").value;
const clientKey = document.getElementById("client-key-input").value;
if (typeof(serverName) !== "string" || typeof(clientKey) !== "string") {
return;
}
const dialog = document.getElementById("request-dialog");
closeRequestDialog();
try {
const response = await fetch(`/api/servers/${serverName}/requests`, {
method: "POST",
headers: {
"X-Client-Key": clientKey
}
});
if (!response.ok) {
showMessageDialog("The request was rejected: " + response.status);
} else {
showMessageDialog("The request was submitted.");
}
} catch (error) {
showMessageDialog("Failed to submit request: " + error);
}
}
function showMessageDialog(message) {
const dialog = document.getElementById("message-dialog");
const p = dialog.children[0];
p.innerText = message;
dialog.showModal();
window.setTimeout(() => dialog.close(), 3000);
}
async function refreshServers() {
const containerDiv = document.getElementById("servers-container");
try {
const servers = await getServers();
containerDiv.replaceChildren(...servers.map(s => makeServerListingDiv(s)));
} catch (error) {
console.error("Failed to fetch servers from the API.", error);
const errorMessage = document.createElement("p");
errorMessage.innerText = "Failed to fetch servers from the API. " + error;
errorMessage.style = "color: red";
containerDiv.replaceChildren(errorMessage);
}
}
refreshServers();
window.setInterval(refreshServers, 1000);
</script>
</body>
</html>

View File

@ -4,7 +4,11 @@
], ],
"copyright": "Copyright © 2024, Andrew Lalis", "copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": { "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.", "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", "license": "proprietary",

16
api/dub.selections.json Normal file
View File

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

View File

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

117
api/source/app.d Normal file
View File

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

6
deploy-agent.sh Executable file
View File

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

10
deploy-web.sh Executable file
View File

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

View File

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

16
shared-utils/.gitignore vendored Normal file
View File

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

13
shared-utils/dub.json Normal file
View File

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

View File

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

View File

@ -0,0 +1,3 @@
module shared_utils;
public import shared_utils.server_status;

View File

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

View File

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

View File

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

View File

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