commit eb92d2d36f21650a5ff24b96628a28611c5d5709 Author: Malasaur Date: Sun Nov 30 15:47:45 2025 +0100 Genesis commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..542b893 Binary files /dev/null and b/__pycache__/__init__.cpython-313.pyc differ diff --git a/__pycache__/classes.cpython-313.pyc b/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000..51dccec Binary files /dev/null and b/__pycache__/classes.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..02188a3 Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/controllers.cpython-313.pyc b/__pycache__/controllers.cpython-313.pyc new file mode 100644 index 0000000..7c0c8f5 Binary files /dev/null and b/__pycache__/controllers.cpython-313.pyc differ diff --git a/__pycache__/libminecraftd.cpython-313.pyc b/__pycache__/libminecraftd.cpython-313.pyc new file mode 100644 index 0000000..bfe03c1 Binary files /dev/null and b/__pycache__/libminecraftd.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..437c4ed Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..aa41df9 Binary files /dev/null and b/__pycache__/models.cpython-313.pyc differ diff --git a/__pycache__/responses.cpython-313.pyc b/__pycache__/responses.cpython-313.pyc new file mode 100644 index 0000000..39cde34 Binary files /dev/null and b/__pycache__/responses.cpython-313.pyc differ diff --git a/__pycache__/util.cpython-313.pyc b/__pycache__/util.cpython-313.pyc new file mode 100644 index 0000000..702c65c Binary files /dev/null and b/__pycache__/util.cpython-313.pyc differ diff --git a/classes.py b/classes.py new file mode 100644 index 0000000..ca48faf --- /dev/null +++ b/classes.py @@ -0,0 +1,14 @@ +from typing import NotRequired, TypedDict + + +class ServerPlayersList(TypedDict): + online: int + max: int + list: list[str] + + +class ServerStatus(TypedDict): + online: bool + icon: NotRequired[str | None] + motd: NotRequired[str] + players: NotRequired[ServerPlayersList] diff --git a/config.py b/config.py new file mode 100644 index 0000000..f90de32 --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +from dotenv import dotenv_values + + +class Config: + data = dotenv_values() + MINECRAFTD_PASSWORD: str | None = data.get("MINECRAFTD_PASSWORD") + PID_FILE: str = data.get("PID_FILE") or "server.pid" + START_COMMAND: str = data.get("START_COMMAND") or "python proc.py" + SERVER_HOST: str = data.get("SERVER_HOST") or "localhost" + SERVER_PORT: int = int(data.get("SERVER_PORT") or 25565) + SERVER_RCON_PORT: int = int(data.get("SERVER_RCON_PORT") or 25575) + SERVER_RCON_PASSWORD: str | None = data.get("SERVER_RCON_PASSWORD") + MAINTAINANCE_FILE = data.get("MAINTAINANCE_FILE") or "maintainance.txt" + LOG_FILE = data.get("LOG_FILE") or "logs.txt" diff --git a/controllers.py b/controllers.py new file mode 100644 index 0000000..24ddf33 --- /dev/null +++ b/controllers.py @@ -0,0 +1,155 @@ +import shlex +from collections import deque +from os import setsid +from pathlib import Path +from subprocess import PIPE, Popen +from time import sleep +from typing import Generator + +from mcrcon import MCRcon +from mcstatus import JavaServer +from psutil import NoSuchProcess, Process + +from .classes import ServerStatus +from .config import Config + + +class ProcessController: + def __init__(self): + self.pid_file: Path = Path(Config.PID_FILE) + self.start_command: list[str] = shlex.split(Config.START_COMMAND) + self.process: Process | None = None + + if self.pid_file.is_file(): + pid = int(self.pid_file.read_text()) + if self._is_pid_alive(pid): + self.process = Process(pid) + else: + self.pid_file.unlink() + + def start(self) -> None: + "Starts the process." + if self.is_started(): + return + + process = Popen( + self.start_command, + stdout=PIPE, + stderr=PIPE, + preexec_fn=setsid, + ) + self.process = Process(process.pid) + self.pid_file.write_text(str(self.process.pid)) + + def is_started(self) -> bool: + "Check if the process is running." + if self.process: + return self.process.is_running() + return False + + def is_crashed(self) -> bool: + "Check if the process has crashed." + return False # TODO + + def kill(self) -> None: + "Kill the process." + if self.process: + self.process.terminate() + self.process = None + self.pid_file.unlink() + + def _is_pid_alive(self, pid: int) -> bool: + "Check if a process with the given PID is alive." + try: + p = Process(pid) + return p.is_running() + except NoSuchProcess: + return False + + +class ServerController: + def __init__(self): + self.server: JavaServer = JavaServer( + Config.SERVER_HOST, + Config.SERVER_PORT, + ) + self.rcon = MCRcon( + Config.SERVER_HOST, + Config.SERVER_RCON_PASSWORD, + Config.SERVER_RCON_PORT, + ) + + def status(self) -> ServerStatus: + try: + status = self.server.status() + except Exception: + return {"online": False} + + players = [] + if status.players.sample: + for player in status.players.sample: + players.append(player.name) + + return { + "online": True, + "icon": status.icon, + "motd": status.motd.to_html(), + "players": { + "online": status.players.online, + "max": status.players.max, + "list": players, + }, + } + + def command(self, command: str) -> str: + self.rcon.connect() + output = self.rcon.command(command) + self.rcon.disconnect() + return output + + +class MaintainanceController: + def __init__(self): + self.mnt_file = Path(Config.MAINTAINANCE_FILE) + + def set(self, reason: str): + self.mnt_file.write_text(reason) + + def is_set(self) -> bool: + return self.mnt_file.is_file() + + def get(self) -> str: + if self.is_set(): + return self.mnt_file.read_text() + return "" + + def unset(self): + if self.is_set(): + self.mnt_file.unlink() + + +class LogsController: + def __init__(self): + self.log_file = Path(Config.LOG_FILE) + + def stream(self) -> Generator[str]: + with self.log_file.open() as f: + f.seek(0, 2) + while True: + line = f.readline() + if line: + yield line + else: + sleep(0.1) + + def tail(self, back: int = 10) -> Generator[str]: + with self.log_file.open() as f: + for line in deque(f, maxlen=back): + yield line + + +class Controllers: + process = ProcessController() + server = ServerController() + maintainance = MaintainanceController() + logs = LogsController() diff --git a/main.py b/main.py new file mode 100644 index 0000000..5f74ce4 --- /dev/null +++ b/main.py @@ -0,0 +1,120 @@ +from asyncio import create_task +from typing import Annotated + +from fastapi import FastAPI, Header +from fastapi.responses import StreamingResponse + +from .controllers import Controllers +from .models import Models +from .responses import Responses +from .util import check_password, stop_server + +app = FastAPI() + + +@app.get("/start") +async def start() -> Responses.StartResponse: + "Starts the Server process." + + if Controllers.process.is_started(): + return {"status": "running"} + Controllers.process.start() + return {"status": "started"} + + +@app.get("/status") +async def status() -> Responses.StatusResponse: + "Checks whether the Server is running and returns its information." + + if not Controllers.process.is_started(): + # Crashed + if Controllers.process.is_crashed(): + return {"status": "crashed"} + # Maintainance + if Controllers.maintainance.is_set(): + return {"status": "maintainance", "reason": Controllers.maintainance.get()} + # Offline + return {"status": "offline"} + + status = Controllers.server.status() + + # Starting + if not status["online"]: + return {"status": "starting"} + + # Online + return { + "status": "online", + "motd": status.get("motd", ""), + "icon": status.get("icon", None), + "players": status.get( + "players", + { + "online": 0, + "max": 20, + "list": [], + }, + ), + } + + +@app.get("/stop") +async def stop(data: Models.StopModel, authorization: Annotated[str, Header()]): + "Stops the Server." + check_password(authorization) + create_task(stop_server("STOPPING", data.countdown, data.reason, data.timeout)) + + +@app.get("/restart") +async def restart(data: Models.RestartModel, authorization: Annotated[str, Header()]): + "Restarts the Server." + check_password(authorization) + create_task( + stop_server( + "RESTARTING", + data.countdown, + data.reason, + data.timeout, + Controllers.process.start, + ) + ) + + +@app.get("/maintainance") +async def maintainance( + data: Models.MaintainanceModel, authorization: Annotated[str, Header()] +): + "Stops the Server and sets it to maintainance status." + check_password(authorization) + create_task( + stop_server( + "STOPPING FOR MAINTAINANCE", + data.countdown, + data.reason, + data.timeout, + Controllers.maintainance.set(data.reason), + ) + ) + + +@app.get("/command") +async def command( + data: Models.CommandModel, authorization: Annotated[str, Header()] +) -> str: + "Runs a command on the Server and returns its output." + check_password(authorization) + return Controllers.server.command(data.command) + + +@app.get("/logs/stream") +async def logs_stream(authorization: Annotated[str, Header()]) -> StreamingResponse: + check_password(authorization) + return StreamingResponse(Controllers.logs.stream(), media_type="text/event-stream") + + +@app.get("/logs/tail") +async def logs_tail( + data: Models.LogsTailModel, authorization: Annotated[str, Header()] +) -> StreamingResponse: + check_password(authorization) + return StreamingResponse(Controllers.logs.tail(data.back)) diff --git a/models.py b/models.py new file mode 100644 index 0000000..3370743 --- /dev/null +++ b/models.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + + +class StopModel(BaseModel): + countdown: int = 60 + reason: str = "" + timeout: int = 10 + + +class RestartModel(BaseModel): + countdown: int = 60 + reason: str = "" + timeout: int = 10 + + +class MaintainanceModel(BaseModel): + countdown: int = 60 + reason: str = "" + timeout: int = 10 + + +class CommandModel(BaseModel): + command: str + + +class LogsTailModel(BaseModel): + back: int = 10 + + +class Models: + StopModel = StopModel + RestartModel = RestartModel + MaintainanceModel = MaintainanceModel + CommandModel = CommandModel + LogsTailModel = LogsTailModel diff --git a/responses.py b/responses.py new file mode 100644 index 0000000..b9383f3 --- /dev/null +++ b/responses.py @@ -0,0 +1,25 @@ +from typing import Literal, NotRequired, TypedDict + + +class StartResponse(TypedDict): + status: Literal["running", "started"] + + +class StatusResponsePlayers(TypedDict): + online: int + max: int + list: list[str] + + +class StatusResponse(TypedDict): + status: Literal["online", "offline", "crashed", "maintainance", "starting"] + reason: NotRequired[str] + motd: NotRequired[str] + icon: NotRequired[str | None] + players: NotRequired[StatusResponsePlayers] + + +class Responses: + StartResponse = StartResponse + StatusResponsePlayers = StatusResponsePlayers + StatusResponse = StatusResponse diff --git a/util.py b/util.py new file mode 100644 index 0000000..5c7d38a --- /dev/null +++ b/util.py @@ -0,0 +1,45 @@ +from asyncio import sleep +from typing import Callable + +from fastapi import HTTPException + +from .config import Config +from .controllers import Controllers + + +async def stop_server( + action: str, countdown: int, reason: str, timeout: int, then: Callable | None = None +): + """Warns the players, stops the Server, and kills its process if its taking too long. + + Parameters: + action (str): Action to write in the warning ("SERVER IS {action}..."). + countdown (int): Seconds to wait after the warning. + reason: (str): The reason for the Server shutdown. + timeout (int): Seconds to wait before killing the process. + then (Callable | None) (default: None): Function to be called after the Server is stopped. + """ + if countdown: + Controllers.server.command(f"say SERVER IS {action} IN {countdown} SECONDS!!!") + Controllers.server.command(f"say REASON: '{reason}'") + while countdown > 0 and Controllers.server.status().get("players", {}).get( + "online", 0 + ): + await sleep(1) + countdown -= 1 + + # Controllers.server.command("stop") + while timeout > 0 and Controllers.process.is_started(): + await sleep(1) + timeout -= 1 + + if Controllers.process.is_started(): + Controllers.process.kill() + + if then: + then() + + +def check_password(password: str): + if password != Config.MINECRAFTD_PASSWORD: + raise HTTPException(401, "Password is invalid")