Added agent, refactored API, working version now.
This commit is contained in:
parent
15a7adff7e
commit
7664186559
18
README.md
18
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.
|
||||
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.
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Run MC Server Agent Periodically
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=5
|
||||
AccuracySec=1s
|
||||
[Install]
|
||||
WantedBy=timers.target
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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",
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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");
|
||||
}
|
|
@ -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
|
|
@ -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'
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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"
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
module shared_utils;
|
||||
|
||||
public import shared_utils.server_status;
|
|
@ -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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
76
source/app.d
76
source/app.d
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue