Genesis commit

This commit is contained in:
Malasaur 2025-09-03 12:57:51 +02:00
commit 0736d1fe08
4 changed files with 181 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
venv/
.env
program.py
test.py

71
handler.py Normal file
View file

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

38
lib.py Normal file
View file

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

67
main.py Normal file
View file

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