From 0736d1fe08ff3d173ef32a73a6450c0e0ba11f82 Mon Sep 17 00:00:00 2001 From: Malasaur Date: Wed, 3 Sep 2025 12:57:51 +0200 Subject: [PATCH] Genesis commit --- .gitignore | 5 ++++ handler.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib.py | 38 +++++++++++++++++++++++++++++ main.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 .gitignore create mode 100644 handler.py create mode 100644 lib.py create mode 100644 main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c94f344 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +venv/ +.env +program.py +test.py \ No newline at end of file diff --git a/handler.py b/handler.py new file mode 100644 index 0000000..68981e4 --- /dev/null +++ b/handler.py @@ -0,0 +1,71 @@ +from typing import AsyncIterator, List +from asyncio.subprocess import PIPE +import asyncio + + +class ServerHandler: + def __init__(self, argv: List[str]): + self.argv = argv + self._proc: asyncio.subprocess.Process | None = None + self._stdout_queue: asyncio.Queue[str | None] = asyncio.Queue() + self._reader_task: asyncio.Task | None = None + self._stderr_task: asyncio.Task | None = None + self._watcher_task: asyncio.Task | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._proc and self._proc.returncode is None: + return + + self._stop_event = asyncio.Event() + self._stdout_queue = asyncio.Queue() + + self._proc = await asyncio.create_subprocess_exec( + *self.argv, + stdout=PIPE, + stderr=PIPE, + ) + + loop = asyncio.get_running_loop() + self._reader_task = loop.create_task( + self._read_stream(self._proc.stdout)) + self._stderr_task = loop.create_task( + self._read_stream(self._proc.stderr)) + self._watcher_task = loop.create_task(self._watch_process()) + + async def _read_stream(self, stream: asyncio.StreamReader | None) -> None: + if stream is None: + return + try: + while not stream.at_eof(): + line_bytes = await stream.readline() + if not line_bytes: + break + + try: + line = line_bytes.decode(errors="replace").rstrip("\n") + except Exception: + line = line_bytes.decode("utf-8", "replace").rstrip("\n") + + print(line) + await self._stdout_queue.put(line) + except asyncio.CancelledError: + ... + finally: + await self._stdout_queue.put(None) + + async def _watch_process(self) -> None: + if self._proc is not None: + await self._proc.wait() + self._stop_event.set() + for t in (self._reader_task, self._stderr_task): + if t and not t.done(): + t.cancel() + await self._stdout_queue.put(None) + + async def reader(self) -> AsyncIterator[str]: + while True: + item = await self._stdout_queue.get() + if item is None: + break + yield item diff --git a/lib.py b/lib.py new file mode 100644 index 0000000..a980a82 --- /dev/null +++ b/lib.py @@ -0,0 +1,38 @@ +from typing import AsyncIterator +from pydantic import BaseModel +import asyncio + + +class CommandModel(BaseModel): + password: str + cmd: str + + +class LogsModel(BaseModel): + password: str + + +def sse_event(data: str, event: str | None = None, id: str | None = None) -> bytes: + parts = [] + if id is not None: + parts.append(f"id: {id}") + if event is not None: + parts.append(f"event: {event}") + for line in data.rstrip("\n").split("\n"): + parts.append(f"data: {line}") + parts.append("") + return ("\n".join(parts) + "\n").encode("utf-8") + + +async def sse_from_iterator(iterator: AsyncIterator[str], keep_alive: float = 15): + ait = iterator.__aiter__() + while True: + try: + item = await asyncio.wait_for(ait.__anext__(), timeout=keep_alive) + except asyncio.TimeoutError: + yield b": keep-alive\n\n" + continue + except StopAsyncIteration: + break + yield sse_event(item) + yield sse_event("stream closed", event="close") diff --git a/main.py b/main.py new file mode 100644 index 0000000..f26d250 --- /dev/null +++ b/main.py @@ -0,0 +1,67 @@ +from fastapi.responses import StreamingResponse +from fastapi import HTTPException +from mcstatus import JavaServer +from dotenv import load_dotenv +from fastapi import FastAPI +from mcrcon import MCRcon +from os import environ + +from lib import CommandModel, LogsModel, sse_from_iterator +from handler import ServerHandler + +load_dotenv() +MCSMGR_PASSWORD = environ.get("MCSMGR_PASSWORD", "SuperSecretPassword") +MINECRAFT_SERVER_COMMAND = environ.get( + "MINECRAFT_SERVER_COMMAND", "java -jar fabric.jar nogui") +MINECRAFT_SERVER_ADDRESS = environ.get("MINECRAFT_SERVER_ADDRESS", "localhost") +MINECRAFT_SERVER_RCON_PASSWORD = environ.get( + "MINECRAFT_SERVER_RCON_PASSWORD", "SuperSecretPassword") + +app = FastAPI() +handler = ServerHandler(MINECRAFT_SERVER_COMMAND.split()) +server = JavaServer(MINECRAFT_SERVER_ADDRESS) + + +@app.get("/status") +async def status(): + try: + info = server.status() + except: + return {"online": False} + + return { + "online": True, + "latency": info.latency, + "motd": info.motd, + "players": { + "list": info.players.sample, + "online": info.players.online, + "max": info.players.max, + }, + } + + +@app.post("/command") +async def command(data: CommandModel): + if data.password != MCSMGR_PASSWORD: + raise HTTPException(403, "Invalid password") + + try: + with MCRcon(MINECRAFT_SERVER_ADDRESS, MINECRAFT_SERVER_RCON_PASSWORD) as mcr: + return mcr.command(data.cmd) + except: + raise HTTPException(500, "Unable to reach Server") + + +@app.post("/start") +async def start(): + await handler.start() + + +@app.get("/logs") +async def logs(data: LogsModel): + if data.password != MCSMGR_PASSWORD: + raise HTTPException(401, "Invalid password") + + generator = sse_from_iterator(handler.reader()) + return StreamingResponse(generator, media_type="text/event-stream")