#!/usr/bin/env python3 import hashlib import hmac import json import os import shutil import signal import socket import subprocess import sys import threading import time import traceback from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse BUILD = "v11" DATA_DIR = os.environ.get("MC_DATA", "/data") JAR = os.environ.get("MC_JAR", "/server/server.jar") MEM = os.environ.get("MC_MEM", "4G") HTTP_PORT = int(os.environ.get("STATUS_PORT", "7860")) CONTROL_PASSWORD = os.environ.get("CONTROL_PASSWORD", "") PLAYIT_SECRET = os.environ.get("PLAYIT_SECRET", "") MAX_LOG = 4000 PLAYIT_TRAIL = 40 class MCController: def __init__(self): self.lock = threading.RLock() self.lines = deque(maxlen=MAX_LOG) self.ids = deque(maxlen=MAX_LOG) self.next_id = 0 self.playit_trail = deque(maxlen=PLAYIT_TRAIL) self.proc = None self.playit = None self.stopped = False self.pending_restart = False self.crashes = 0 self.started_at = None self._kill_timer = None def push(self, text, source="mc"): line = str(text) with self.lock: self.lines.append(line) self.ids.append(self.next_id) self.next_id += 1 if source == "playit": self.playit_trail.append(line) def log_from(self, after, limit=500): with self.lock: pairs = list(zip(self.ids, self.lines)) if after < 0: pairs = pairs[-limit:] out = [line for _, line in pairs] else: out = [line for i, line in pairs if i > after][:limit] last = pairs[-1][0] if pairs else after return out, last def start_playit(self): if self.playit is not None and self.playit.poll() is None: return if not PLAYIT_SECRET: self.push("[panel] PLAYIT_SECRET unset - tunnel disabled, server is LAN-only", "playit") return self.push("[panel] starting playitd", "playit") env = dict(os.environ) runtime_dir = "/tmp/playitd" try: os.makedirs(runtime_dir, mode=0o700, exist_ok=True) except OSError as e: self.push(f"[panel] playitd runtime dir: {e}", "playit") env["XDG_RUNTIME_DIR"] = runtime_dir try: self.playit = subprocess.Popen( ["playitd", "--secret", PLAYIT_SECRET, "--platform-docker", "--socket-path", os.path.join(runtime_dir, "ipc.sock")], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", env=env, ) except FileNotFoundError: self.push("[panel] playitd binary missing - tunnel disabled", "playit") self.playit = None return threading.Thread(target=self._playit_reader, args=(self.playit,), daemon=True).start() def _playit_reader(self, proc): for line in proc.stdout: self.push(line.rstrip(), "playit") code = proc.wait() self.push(f"[playit] exited with code {code}", "playit") def playit_state(self): if not PLAYIT_SECRET: return "disabled" p = self.playit if p is None: return "offline" if p.poll() is not None: return "offline" text = " ".join(self.playit_trail).lower() if "connected" in text: return "connected" if "error" in text or "refused" in text: return "error" return "connecting" def start_java(self): with self.lock: if self.proc is not None and self.proc.poll() is None: return self.proc = None if not os.path.exists(os.path.join(DATA_DIR, "eula.txt")): self.ensure_conf() self.push("[panel] starting Minecraft server") env = dict(os.environ) args = ["java", "-Xms" + MEM, "-Xmx" + MEM, "-jar", JAR, "nogui"] self.proc = subprocess.Popen( args, cwd=DATA_DIR, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, ) self.started_at = time.time() self._kill_timer = None threading.Thread(target=self._mc_reader, args=(self.proc,), daemon=True).start() def ensure_conf(self): eula_path = os.path.join(DATA_DIR, "eula.txt") props_path = os.path.join(DATA_DIR, "server.properties") if not os.path.exists(eula_path): with open(eula_path, "w") as f: f.write("eula=true\n") if not os.path.exists(props_path): with open(props_path, "w") as f: f.write("server-port=25565\nonline-mode=false\nmax-players=20\nview-distance=4\nsimulation-distance=4\n") def _mc_reader(self, proc): for line in proc.stdout: self.push(line.rstrip()) code = proc.wait() self._on_mc_exit(code) def _on_mc_exit(self, code): with self.lock: self.proc = None if self.stopped: self.push(f"[panel] server stopped (exit {code})") return if self.pending_restart: self.pending_restart = False self.push(f"[panel] restarted by panel (exit {code})") restart_delay = 0.0 else: self.crashes += 1 self.push(f"[panel] server exited ({code}) - restarting in 3s") restart_delay = 3.0 if restart_delay <= 0: self.start_java() else: timer = threading.Timer(restart_delay, self.start_java) timer.daemon = True timer.start() def send_command(self, cmd): cmd = str(cmd).strip() if not cmd: return False, "empty command" with self.lock: proc = self.proc if proc is None or proc.poll() is not None: return False, "server is not running" try: proc.stdin.write(cmd + "\n") proc.stdin.flush() except (BrokenPipeError, ValueError) as e: return False, str(e) self.push(f"[panel] > {cmd}") return True, "sent" def restart(self): with self.lock: self.stopped = False self.pending_restart = True proc = self.proc if self.proc is not None and self.proc.poll() is None else None if proc is not None: self._arm_kill_timer() self._write_stdin(proc, "stop") if proc is None: self.start_java() return "restart requested" def _arm_kill_timer(self): def kill(): with self.lock: p = self.proc if p is not None and self.pending_restart and p.poll() is None: self.push("[panel] force-stopping server") p.terminate() self._kill_timer = threading.Timer(45.0, kill) self._kill_timer.daemon = True self._kill_timer.start() def wipe(self): with self.lock: world = os.path.join(DATA_DIR, "world") if os.path.isdir(world): shutil.rmtree(world, ignore_errors=True) self.push("[panel] world wiped - fresh world will generate") else: self.push("[panel] no world dir to wipe") self.stopped = False self.pending_restart = False running = self.proc is not None and self.proc.poll() is None if not running: self.start_java() return "world reset" def stop(self): with self.lock: self.stopped = True self.pending_restart = False proc = self.proc if self.proc is not None and self.proc.poll() is None else None if proc is not None: self._write_stdin(proc, "stop") if proc is None: return "server not running" return "server stopping" @staticmethod def _write_stdin(proc, cmd): try: proc.stdin.write(cmd + "\n") proc.stdin.flush() except (BrokenPipeError, ValueError): pass def status(self): with self.lock: proc = self.proc proc_alive = proc is not None and proc.poll() is None up = int(time.time() - self.started_at) if proc_alive and self.started_at else 0 rss = self._rss_kb(proc.pid) if proc_alive else 0 world = os.path.isdir(os.path.join(DATA_DIR, "world")) return { "world": "exists" if world else "none", "probe": getattr(self, "boot_probe", 0), "mc": "running" if proc_alive else "stopped", "uptime": up, "crashes": self.crashes, "mem_kb": rss, "mem_gb": round(rss / (1024 * 1024), 2), "playit": self.playit_state(), "mem_heap": MEM, "ver": BUILD, } @staticmethod def _rss_kb(pid): try: with open(f"/proc/{pid}/status") as f: for line in f: if line.startswith("VmRSS:"): return int(line.split()[1]) except (OSError, ValueError, IndexError): pass return 0 def graceful(self): with self.lock: self.stopped = True self.pending_restart = False p = self.proc q = self.playit self.push("[panel] shutting down") for target in (p, q): if target is not None and target.poll() is None: try: os.kill(target.pid, signal.SIGTERM) except OSError: pass for target, timeout in ((p, 20), (q, 10)): if target is not None: try: target.wait(timeout=timeout) except subprocess.TimeoutExpired: try: target.kill() except OSError: pass controller = MCController() def token_for(password): return hashlib.sha256(("panel-control:" + password).encode("utf-8")).hexdigest() def auth_ok(tok): if not CONTROL_PASSWORD: return True if not tok: return False return hmac.compare_digest(tok, token_for(CONTROL_PASSWORD)) class Handler(BaseHTTPRequestHandler): server_version = "MCPanel/1.0" def _send(self, code, body, ctype): if isinstance(body, str): body = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def _json(self, code, obj): self._send(code, json.dumps(obj, separators=(",", ":")), "application/json") def _read_post(self): raw = self._read_body() or b"" try: obj = json.loads(raw.decode("utf-8")) if raw else None except (ValueError, json.JSONDecodeError): obj = None return obj, raw def _read_body(self): te = (self.headers.get("Transfer-Encoding") or "").lower() if te == "chunked": data = bytearray() while True: size_line = self.rfile.readline().strip() try: size = int(size_line.split(b";")[0], 16) except (ValueError, IndexError): break if size == 0: self.rfile.readline() break data += self.rfile.read(size) self.rfile.readline() return bytes(data) cl = int(self.headers.get("Content-Length") or 0) if 0 < cl <= 1 << 20: return self.rfile.read(cl) sock = self.connection sock.settimeout(0.5) try: data = bytearray() while True: try: chunk = self.rfile.read(4096) except (socket.timeout, OSError): break if not chunk: break data += chunk if len(data) > 1 << 20: break return bytes(data) if data else None finally: sock.settimeout(None) def log_message(self, fmt, *args): pass def handle(self): try: super().handle() except Exception: controller.push("[panel] conn error: " + traceback.format_exc().splitlines()[-1]) raise def do_GET(self): u = urlparse(self.path) path = u.path params = {k: v[-1] for k, v in parse_qs(u.query).items()} if path == "/api/status": self._json(200, controller.status()) elif path == "/api/log": try: after = int(params.get("after", "-1")) limit = int(params.get("limit", "500")) except ValueError: after, limit = -1, 500 lines, last = controller.log_from(after, limit) self._json(200, {"lines": lines, "last": last}) elif path == "/api/command": if not auth_ok(self.headers.get("X-Token", "")): self._json(401, {"ok": False, "error": "auth required"}) return ok, msg = controller.send_command(params.get("cmd", "")) self._json(200 if ok else 400, {"ok": ok, "error": msg}) elif path == "/api/action": if not auth_ok(self.headers.get("X-Token", "")): self._json(401, {"ok": False, "error": "auth required"}) return action = params.get("action") if action == "restart": self._json(200, {"ok": True, "error": controller.restart()}) elif action == "stop": self._json(200, {"ok": True, "error": controller.stop()}) elif action == "wipe": self._json(200, {"ok": True, "error": controller.wipe()}) else: self._json(400, {"ok": False, "error": "unknown action"}) else: self._send(200, PAGE, "text/html; charset=utf-8") def do_POST(self): controller.push("[panel] POST " + self.path + " cl=" + str(self.headers.get("Content-Length")) + " te=" + str(self.headers.get("Transfer-Encoding"))) u = urlparse(self.path) try: self._do_post(u) except Exception as e: controller.push("[panel] handler error: " + traceback.format_exc().splitlines()[-1]) self._json(500, {"ok": False, "error": "internal: " + repr(e)}) def _do_post(self, u): raw = self._read_body() or b"" params = self._params(u, raw) if u.path == "/api/login": if CONTROL_PASSWORD and params.get("password") == CONTROL_PASSWORD: self._json(200, {"ok": True, "token": token_for(CONTROL_PASSWORD)}) elif CONTROL_PASSWORD: self._json(401, {"ok": False, "error": "wrong password"}) else: self._json(200, {"ok": True, "token": ""}) return if u.path == "/api/command": if not auth_ok(self.headers.get("X-Token", "")): self._json(401, {"ok": False, "error": "auth required"}) return ok, msg = controller.send_command(params.get("cmd", "")) self._json(200 if ok else 400, {"ok": ok, "error": msg}) elif u.path == "/api/action": if not auth_ok(self.headers.get("X-Token", "")): self._json(401, {"ok": False, "error": "auth required"}) return action = params.get("action") if action == "restart": self._json(200, {"ok": True, "error": controller.restart()}) elif action == "stop": self._json(200, {"ok": True, "error": controller.stop()}) elif action == "wipe": self._json(200, {"ok": True, "error": controller.wipe()}) else: self._json(400, {"ok": False, "error": "unknown action"}) else: self._json(404, {"ok": False, "error": "not found"}) def _params(self, u, raw): params = {} for key, vals in parse_qs(u.query).items(): params[key] = vals[-1] ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower() if ctype == "application/x-www-form-urlencoded" and raw: for key, vals in parse_qs(raw.decode("utf-8", "replace")).items(): params[key] = vals[-1] if raw: try: obj = json.loads(raw.decode("utf-8")) if isinstance(obj, dict): for key, val in obj.items(): params[str(key)] = val except (ValueError, json.JSONDecodeError): pass return params PAGE = r"""