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