summaryrefslogtreecommitdiff
path: root/hosts
diff options
context:
space:
mode:
authorLinnnus <[email protected]>2025-02-02 20:26:54 +0100
committerLinnnus <[email protected]>2025-02-02 20:32:34 +0100
commite75d377abb059340f17c571089f14ffb7bbd6bbe (patch)
tree7f4dff882236b5a6218e2750630a4eefd0bc6bd0 /hosts
parent8e4bb609374af47a74d06af708f75ae17c654d8a (diff)
ahmed/minecraft-log-server: Init
Diffstat (limited to 'hosts')
-rw-r--r--hosts/ahmed/configuration.nix1
-rw-r--r--hosts/ahmed/minecraft-log-server/.htpasswd1
-rw-r--r--hosts/ahmed/minecraft-log-server/default.nix107
-rw-r--r--hosts/ahmed/minecraft-log-server/minecraft_log_server.py122
-rw-r--r--hosts/ahmed/minecraft-log-server/public/index.html11
-rw-r--r--hosts/ahmed/minecraft-log-server/public/scripts/plain.js68
-rw-r--r--hosts/ahmed/minecraft-log-server/public/scripts/reconnecting-eventsource.min.js2
-rw-r--r--hosts/ahmed/minecraft-log-server/public/styles/plain.css7
8 files changed, 319 insertions, 0 deletions
diff --git a/hosts/ahmed/configuration.nix b/hosts/ahmed/configuration.nix
index 25eb336..aab26c3 100644
--- a/hosts/ahmed/configuration.nix
+++ b/hosts/ahmed/configuration.nix
@@ -22,6 +22,7 @@
./remote-builder
./dyndns
./minecraft
+ ./minecraft-log-server
./nginx
];
diff --git a/hosts/ahmed/minecraft-log-server/.htpasswd b/hosts/ahmed/minecraft-log-server/.htpasswd
new file mode 100644
index 0000000..5cb2c4c
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/.htpasswd
@@ -0,0 +1 @@
+Gæst:$2y$05$R1XvCulPi3xjTB31b1MuEugyX.Sk0Bkh1m95.wseA1ydJ3Rq9V.j2
diff --git a/hosts/ahmed/minecraft-log-server/default.nix b/hosts/ahmed/minecraft-log-server/default.nix
new file mode 100644
index 0000000..1c18915
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/default.nix
@@ -0,0 +1,107 @@
+# This module implements a really simple and shitty WSGI app which streams
+# journald logs to the client. I don't expect it to hang around for long, so
+# it's just quickly hacked together.
+#
+# FIXME: There's still the issue with broken connections. Perhaps heartbeat would fix.
+{
+ pkgs,
+ config,
+ ...
+}: let
+ socket-path = "/run/minecraft-log-server.sock";
+
+ python = pkgs.python3.withPackages (ps:
+ with ps; [
+ gevent
+ gunicorn
+ ]);
+in {
+ users.users.minecraft-log-server = {
+ description = "Runs minecraft-log-server";
+ group = "minecraft-log-server";
+ isSystemUser = true;
+ };
+ users.groups.minecraft-log-server = {};
+
+ systemd.sockets.minecraft-log-server = {
+ description = "Socket where the service of the same name answers HTTP requests.";
+
+ socketConfig = {
+ ListenStream = socket-path;
+
+ # TODO: wtf apple maps
+ SocketUser = "nginx";
+ SocketGroup = "nginx";
+ SocketMode = "600";
+ };
+
+ wantedBy = ["sockets.target"];
+ };
+
+ # See: https://docs.gunicorn.org/en/23.0.0/deploy.html
+ systemd.services.minecraft-log-server = {
+ description = "Minecraft log server";
+
+ serviceConfig = {
+ # Using a non-sync worker class is super important because we have such long-running connections.
+ ExecStart = "${python}/bin/gunicorn --worker-class=gevent --chdir ${./.} minecraft_log_server:app";
+
+ ExecReload = "kill -s HUP $MAINPID";
+ KillMode = "mixed";
+
+ User = config.users.users.minecraft-log-server.uid;
+ Group = config.users.users.minecraft-log-server.group;
+
+ # gunicorn can let systemd know when it is ready
+ Type = "notify";
+ NotifyAccess = "main";
+
+ # Harden
+ ProtectSystem = "strict";
+ PrivateTmp = true;
+ };
+
+ requires = ["minecraft-log-server.socket"]; # Refuse to start without.
+ after = ["network.target"];
+ };
+
+ services.nginx = {
+ virtualHosts."minecraft.linus.onl" = {
+ # Let's be safe and pass-word protect it just in case the logs contain some sensitive data.
+ basicAuthFile = ./.htpasswd;
+
+ # First try resolving files statically, before falling back to the CGI server.
+ locations."/" = {
+ alias = "${./public}/";
+ index = "index.html";
+ tryFiles = "$uri $uri/ @minecraft_log_server";
+ };
+
+ locations."@minecraft_log_server" = {
+ recommendedProxySettings = true;
+
+ # In addition to the important stuff set indirectly via `recommendedProxySettings`
+ # (especially `proxy_http_version`), we need these options for SSE.
+ extraConfig = ''
+ # Disable buffering. This is crucial for SSE to ensure that
+ # messages are sent immediately without waiting for a buffer to
+ # fill.
+ proxy_buffering off;
+
+ # Disable caching to ensure that all messages are sent and received
+ # in real-time without being cached by the proxy.
+ proxy_cache off;
+
+ # Set a long timeout for reading from the proxy to prevent the
+ # connection from timing out. You may need to adjust this value
+ # based on your specific requirements.
+ proxy_read_timeout 86400;
+ '';
+
+ proxyPass = "http://unix:${socket-path}:$request_uri";
+ };
+ };
+ };
+
+ services.cloudflare-dyndns.domains = ["minecraft.linus.onl"];
+}
diff --git a/hosts/ahmed/minecraft-log-server/minecraft_log_server.py b/hosts/ahmed/minecraft-log-server/minecraft_log_server.py
new file mode 100644
index 0000000..30d295d
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/minecraft_log_server.py
@@ -0,0 +1,122 @@
+import subprocess
+import json
+import dataclasses
+import typing as t
+import urllib.parse
+
[email protected](kw_only=True, slots=True)
+class Event:
+ id: t.Optional[str | bytes] = None
+ event: t.Optional[str | bytes] = None
+ data: t.Optional[bytes | bytes] = None
+ retry: t.Optional[int] = None
+
+ def __post_init__(self):
+ if (self.id is None and
+ self.event is None and
+ self.data is None and
+ self.retry is None):
+ raise ValueError("At least one property of event must be non-None: id, event, data, retry")
+
+ def encode(self) -> bytes:
+ """Returns the on-line representation of this event."""
+
+ def to_bytes(s: str | bytes | int) -> bytes:
+ if isinstance(s, str):
+ return s.encode()
+ elif isinstance(s, int):
+ return str(s).encode()
+ else:
+ return s
+
+ # We know the result won't be empty because of the invariant that at least one field is non-None.
+ result = b""
+
+ if self.id: result += b"id: " + to_bytes(self.id) + b"\n"
+ if self.event: result += b"event: " + to_bytes(self.event) + b"\n"
+ if self.data: result += b"data: " + to_bytes(self.data) + b"\n"
+ if self.retry: result += b"retry: " + to_bytes(self.retry) + b"\n"
+
+ # With this final newline, the encoding will end with two newlines, signifying end of event.
+ result += b"\n"
+
+ return result
+
+def app(environ, start_response):
+ print(f"{environ=} {start_response=}") # NOCOMMIT
+ path = environ["PATH_INFO"].lstrip("/")
+ method = environ["REQUEST_METHOD"]
+
+ if method == "GET" and path == "stream":
+ return send_stream(environ, start_response)
+ else:
+ return send_404(environ, start_response)
+
+def send_stream(environ, start_response):
+ status = "200 OK"
+ headers = [("Content-Type", "text/event-stream"),
+ ("Cache-Control", "no-cache"),
+ ("X-Accel-Buffering", "no")]
+ start_response(status, headers)
+
+ # Set the retry rate for when the client looses connection.
+ retry_event = Event(retry=2_000)
+ yield retry_event.encode()
+
+ # Figure out if the client is reconnecting.
+ last_event_id = None
+ if "HTTP_LAST_EVENT_ID" in environ:
+ last_event_id = environ["HTTP_LAST_EVENT_ID"]
+ else:
+ query = urllib.parse.parse_qs(environ["QUERY_STRING"])
+ if "lastEventId" in query:
+ last_event_id = query["lastEventId"][0]
+
+ # FIXME: We should also send heartbeat events to avoid NGINX killing our connection.
+ UNITS = [ "minecraft-listen.socket", "minecraft-listen.service", "minecraft-server.socket",
+ "minecraft-server.service", "minecraft-hook.service", "minecraft-stop.timer",
+ "minecraft-stop.service" ]
+ for event in get_log_entries(UNITS, last_event_id):
+ yield event.encode()
+
+def get_log_entries(units, last_event_id = None) -> t.Generator[Event, None, None]:
+ # TODO: We could save some work by only selecting the fields we're interested in with `--fields`.
+ args = [
+ "/run/current-system/sw/bin/journalctl",
+ # We want a stream
+ "--follow",
+ "--lines=20",
+ # A JSON line for each entry
+ "--output=json",
+ # Use UTC timestamps to avoid tricky timezone issues on the client
+ "--utc",
+ # Log entries from any of the units (logical OR)
+ *(f"--unit={u}" for u in units)
+ ]
+
+ # Since we use the cursor as the SSE event ID, the client will send the
+ # last cursor when retrying connections.
+ if last_event_id:
+ # If this is such a connection, we can avoid including duplicate entries by
+ # starting just after the given cursor.
+ args.append("--after-cursor=" + last_event_id)
+
+ try:
+ process = subprocess.Popen(args, stdout=subprocess.PIPE)
+ assert process.stdout is not None
+ for raw_line in process.stdout:
+ assert raw_line[-2:] == b"}\n", "Raw line ends in single newline"
+ parsed = json.loads(raw_line)
+ event = Event(id=parsed["__CURSOR"],
+ event="entry",
+ data=raw_line.rstrip(b"\n"))
+ yield event
+ except Exception as e:
+ print("Reading (mega sus) journalctl failed", e)
+ raise e
+
+def send_404(environ, start_response):
+ status = "404 Not Found"
+ headers = [("Content-type", "text/plain")]
+ start_response(status, headers)
+ return [b"The requested resource was not found."]
diff --git a/hosts/ahmed/minecraft-log-server/public/index.html b/hosts/ahmed/minecraft-log-server/public/index.html
new file mode 100644
index 0000000..37fdaac
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/public/index.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script type="module" src="./scripts/plain.js"></script>
+ <link rel="stylesheet" href="./styles/plain.css">
+ </head>
+ <body>
+ <p>System events will appear below.</p>
+ <pre><output id="target"></output></pre>
+ </body>
+</html>
diff --git a/hosts/ahmed/minecraft-log-server/public/scripts/plain.js b/hosts/ahmed/minecraft-log-server/public/scripts/plain.js
new file mode 100644
index 0000000..c2da55e
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/public/scripts/plain.js
@@ -0,0 +1,68 @@
+import ReconnectingEventSource from "./reconnecting-eventsource.min.js";
+
+function main() {
+ const sse = new ReconnectingEventSource("/stream", {
+ // Retry time after browser fails to reconnect (e.g. HTTP 502).
+ // This is pretty sus, so let's wait a little longer...
+ max_retry_time: 5_000,
+ });
+
+ sse.addEventListener("open", (event) => {
+ addSpecialMessage("info", "Connection to log server established!");
+ });
+
+ sse.addEventListener("error", (event) => {
+ console.error("SSE Error: ", event);
+ addSpecialMessage("error", "Connection to log server lost! Retrying connection...");
+ });
+
+ sse.addEventListener("entry", (event) => {
+ const line = JSON.parse(event.data);
+ addEntry(line);
+ });
+}
+
+function addEntry(json) {
+ const $container = document.createElement("span");
+ $container.classList.add("regular");
+
+ const $time = document.createElement("time");
+ const timestamp = new Date(+json["__REALTIME_TIMESTAMP"] / 1000);
+ $time.textContent = `[${timestamp.toISOString()}]: `;
+ $time.dateTime = timestamp;
+ $container.append($time)
+
+ const $unit = document.createElement("span");
+ $unit.textContent = json["_SYSTEMD_UNIT"];
+ $container.append($unit);
+ $container.append(": ")
+
+ const $message = document.createElement("span");
+ $message.textContent = json["MESSAGE"];
+ $container.append($message);
+
+ $container.append("\n");
+ addToOutput($container);
+}
+
+function addSpecialMessage(klass, message) {
+ const $message = document.createElement("span");
+ $message.classList.add(klass);
+ $message.textContent = message;
+ $message.textContent += "\n";
+ addToOutput($message);
+}
+
+function addToOutput($elem) {
+ // TODO: Maybe allow for a little wiggle-room?
+ const wasAtBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight;
+
+ const $target = document.getElementById("target");
+ $target.appendChild($elem);
+
+ if (wasAtBottom) {
+ window.scrollTo(0, document.body.scrollHeight);
+ }
+}
+
+main();
diff --git a/hosts/ahmed/minecraft-log-server/public/scripts/reconnecting-eventsource.min.js b/hosts/ahmed/minecraft-log-server/public/scripts/reconnecting-eventsource.min.js
new file mode 100644
index 0000000..344e759
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/public/scripts/reconnecting-eventsource.min.js
@@ -0,0 +1,2 @@
+// https://github.com/fanout/reconnecting-eventsource
+export class EventSourceNotAvailableError extends Error{constructor(){super("EventSource not available.\nConsider loading an EventSource polyfill and making it available globally as EventSource, or passing one in as eventSourceClass to the ReconnectingEventSource constructor.")}}export default class e{_configuration;CONNECTING=0;OPEN=1;CLOSED=2;static CONNECTING=0;static OPEN=1;static CLOSED=2;_eventSource;_lastEventId;_timer;_listeners;_onevent_wrapped;readyState;url;withCredentials;max_retry_time;eventSourceClass;constructor(e,t){if(this._configuration=null!=t?Object.assign({},t):void 0,this.withCredentials=!1,this._eventSource=null,this._lastEventId=null,this._timer=null,this._listeners={},this.url=e.toString(),this.readyState=this.CONNECTING,this.max_retry_time=3e3,this.eventSourceClass=globalThis.EventSource,null!=this._configuration&&(this._configuration.lastEventId&&(this._lastEventId=this._configuration.lastEventId,delete this._configuration.lastEventId),this._configuration.max_retry_time&&(this.max_retry_time=this._configuration.max_retry_time,delete this._configuration.max_retry_time),this._configuration.eventSourceClass&&(this.eventSourceClass=this._configuration.eventSourceClass,delete this._configuration.eventSourceClass)),null==this.eventSourceClass||"function"!=typeof this.eventSourceClass)throw new EventSourceNotAvailableError;this._onevent_wrapped=e=>{this._onevent(e)},this._start()}dispatchEvent(e){throw Error("Method not implemented.")}_start(){let e=this.url;for(let t of(this._lastEventId&&(-1===e.indexOf("?")?e+="?":e+="&",e+="lastEventId="+encodeURIComponent(this._lastEventId)),this._eventSource=new this.eventSourceClass(e,this._configuration),this._eventSource.onopen=e=>{this._onopen(e)},this._eventSource.onerror=e=>{this._onerror(e)},this._eventSource.onmessage=e=>{this.onmessage(e)},Object.keys(this._listeners)))this._eventSource.addEventListener(t,this._onevent_wrapped)}_onopen(e){0===this.readyState&&(this.readyState=1,this.onopen(e))}_onerror(e){if(1===this.readyState&&(this.readyState=0,this.onerror(e)),this._eventSource&&2===this._eventSource.readyState){this._eventSource.close(),this._eventSource=null;let t=Math.round(this.max_retry_time*Math.random());this._timer=setTimeout(()=>this._start(),t)}}_onevent(e){e instanceof MessageEvent&&(this._lastEventId=e.lastEventId);let t=this._listeners[e.type];if(null!=t)for(let s of[...t])s.call(this,e);"message"===e.type&&this.onmessage(e)}onopen(e){}onerror(e){}onmessage(e){}close(){this._timer&&(clearTimeout(this._timer),this._timer=null),this._eventSource&&(this._eventSource.close(),this._eventSource=null),this.readyState=2}addEventListener(e,t,s){e in this._listeners||(this._listeners[e]=[],null!=this._eventSource&&this._eventSource.addEventListener(e,this._onevent_wrapped));let n=this._listeners[e];Array.isArray(n)&&!n.includes(t)&&n.push(t)}removeEventListener(e,t,s){let n=this._listeners[e];if(null!=n){for(;;){let i=n.indexOf(t);if(-1===i)break;n.splice(i,1)}n.length<=0&&(delete this._listeners[e],null!=this._eventSource&&this._eventSource.removeEventListener(e,this._onevent_wrapped))}}};
diff --git a/hosts/ahmed/minecraft-log-server/public/styles/plain.css b/hosts/ahmed/minecraft-log-server/public/styles/plain.css
new file mode 100644
index 0000000..a40b612
--- /dev/null
+++ b/hosts/ahmed/minecraft-log-server/public/styles/plain.css
@@ -0,0 +1,7 @@
+/* Make special messages stand out */
+.error, .info { font-family: serif; font-style: italic; }
+.error { color: red; }
+.info { color: blue; }
+
+/* Make time lower visual priority */
+time { color: grey; }