Genesis commit
This commit is contained in:
commit
eb92d2d36f
17 changed files with 408 additions and 0 deletions
0
__init__.py
Normal file
0
__init__.py
Normal file
BIN
__pycache__/__init__.cpython-313.pyc
Normal file
BIN
__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/classes.cpython-313.pyc
Normal file
BIN
__pycache__/classes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-313.pyc
Normal file
BIN
__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/controllers.cpython-313.pyc
Normal file
BIN
__pycache__/controllers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/libminecraftd.cpython-313.pyc
Normal file
BIN
__pycache__/libminecraftd.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-313.pyc
Normal file
BIN
__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/responses.cpython-313.pyc
Normal file
BIN
__pycache__/responses.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/util.cpython-313.pyc
Normal file
BIN
__pycache__/util.cpython-313.pyc
Normal file
Binary file not shown.
14
classes.py
Normal file
14
classes.py
Normal 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
14
config.py
Normal 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
155
controllers.py
Normal 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
120
main.py
Normal 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
35
models.py
Normal 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
25
responses.py
Normal 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
45
util.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue