Genesis commit

This commit is contained in:
Malasaur 2025-11-30 15:47:45 +01:00
commit eb92d2d36f
No known key found for this signature in database
17 changed files with 408 additions and 0 deletions

0
__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

14
classes.py Normal file
View file

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

14
config.py Normal file
View file

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

155
controllers.py Normal file
View file

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

120
main.py Normal file
View file

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

35
models.py Normal file
View file

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

25
responses.py Normal file
View file

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

45
util.py Normal file
View file

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