HuggingClaw / control.py
Ntk123's picture
Switch to Paper 1.21.11 build 132 (bundler jar, sha256 5ffef4...); BUILD v11
2f4bd8e
Raw History Blame Contribute Delete
26.2 kB
#!/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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minecraft Server Control</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{margin:0;background:#101318;color:#e8e8e8;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:14px}
header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid #262c35;background:#171b21;position:sticky;top:0}
h1{font-size:15px;margin:0;font-weight:600}
.badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid #2b313a;color:#9aa4b0}
.badge b{color:#e8e8e8}
.pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:baseline}
.ok{background:#3fb950}
.warn{background:#d29922}
.err{background:#f85149}
.off{background:#595f66}
.controls{display:flex;align-items:center;gap:8px;color:#9aa4b0;font-size:12px;margin-left:auto}
button{background:#242a33;color:#e8e8e8;border:1px solid #333b47;border-radius:6px;padding:6px 12px;font-family:inherit;font-size:12px;cursor:pointer}
button:hover:not(:disabled){background:#2d3540}
button[data-danger]{background:#3a1f22;border-color:#6b2a30}
button:disabled{opacity:.5;cursor:not-allowed}
main{display:flex;flex-direction:column}
#logbox{margin:0;padding:12px 16px;white-space:pre-wrap;word-break:break-word;flex:1;font-size:13px;line-height:1.55;max-height:calc(100vh - 150px);overflow-y:auto}
.cmdline{display:flex;gap:8px;padding:10px 16px;border-top:1px solid #262c35;background:#171b21}
.cmdline input{flex:1;background:#0d0f12;border:1px solid #333b47;color:#e8e8e8;border-radius:6px;padding:9px 12px;font-family:inherit}
.quick{display:flex;gap:6px;padding:0 16px 10px;flex-wrap:wrap}
.quick button{padding:4px 9px;font-size:11px;color:#9aa4b0}
.faint{color:#7d8792}
#auth{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(5,7,9,.82);z-index:5}
#auth .card{background:#171b21;border:1px solid #2b313a;padding:22px;border-radius:10px;display:flex;flex-direction:column;gap:10px}
#auth input,#auth button{font-family:inherit;padding:8px 10px;border-radius:6px;border:1px solid #333b47}
.row{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid #1c2128;font-size:12px;color:#9aa4b0}
.row span b{color:#e8e8e8;font-weight:600}
</style>
</head>
<body>
<div id="auth"><div class="card">
<div style="font-size:14px">Enter control password</div>
<input id="pw" type="password" placeholder="password" autocomplete="current-password">
<button onclick="doLogin()">Unlock</button>
<div id="pw_err" class="err-row" style="font-size:12px">&nbsp;</div>
</div></div>
<header>
<h1>Minecraft Server</h1>
<span class="badge">MC <b id="mc_state">?</b></span>
<span class="badge">Tunnel <b id="tun_state">?</b></span>
<span class="badge">Heap <b id="heap_badge">?</b></span>
<span class="badge">RAM <b id="ram_badge">?</b></span>
<span class="controls">uptime <b id="uptime"></b> &middot; crashes <b id="crashes"></b></span>
</header>
<div class="row">
<span>Command: <b id="cmd_result" class="faint">&nbsp;</b></span>
</div>
<pre id="logbox"></pre>
<div class="quick">
<button onclick="quick('list')">list</button>
<button onclick="quick('help')">help</button>
<button onclick="quick('save-all')">save-all</button>
<button onclick="quick('say test 123')">say hi</button>
<button onclick="quick('whitelist list')">whitelist list</button>
<button onclick="quick('seed')">seed</button>
<button onclick="quick('tps')">tps</button>
<button style="margin-left:auto" data-danger onclick="doAction('stop')">Stop</button>
<button data-danger onclick="doAction('restart')">Restart</button>
</div>
<div class="cmdline">
<input id="cmd" placeholder="send a server command (e.g. say hello)" autocomplete="off">
<button onclick="sendCmd()">Send</button>
</div>
<script>
let token = sessionStorage.getItem('tok') || '';
let lastId = -1;
const $ = id => document.getElementById(id);
const state = { shouldAutoScroll: true };
function setBadge(id, text, cls) {
const el = $(id);
el.innerHTML = '<i class="pill ' + (cls || 'off') + '"></i>' + text;
}
$('logbox').addEventListener('scroll', () => {
const l = $('logbox');
state.shouldAutoScroll = (l.scrollTop + l.clientHeight) >= l.scrollHeight - 40;
});
function doLogin() {
fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password=' + encodeURIComponent($('pw').value)
}).then(r => {
if (r.status === 401) { $('pw_err').textContent = 'wrong password'; throw 0; }
return r.json();
}).then(d => {
token = d.token || '';
sessionStorage.setItem('tok', token);
if (!token) sessionStorage.setItem('noauth', '1');
$('auth').style.display = 'none';
}).catch(() => {});
}
async function refreshStatus() {
try {
const s = await (await fetch('/api/status?ts=' + Date.now())).json();
setBadge('mc_state', s.mc, s.mc === 'running' ? 'ok' : 'err');
const cls = s.playit === 'connected' ? 'ok' : (s.playit === 'disabled' || s.playit === 'offline' ? 'off' : 'warn');
setBadge('tun_state', s.playit, cls);
$('heap_badge').textContent = s.mem_heap;
$('ram_badge').textContent = s.mem_gb + ' GB';
$('uptime').textContent = fmtUptime(s.uptime);
$('crashes').textContent = s.crashes;
} catch (e) {}
}
function fmtUptime(sec) {
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
return (h ? h + 'h ' : '') + m + 'm ' + s + 's';
}
async function refreshLog() {
try {
const d = await (await fetch('/api/log?after=' + lastId + '&ts=' + Date.now())).json();
lastId = d.last;
if (d.lines.length) {
const l = $('logbox');
for (const ln of d.lines) {
l.appendChild(renderLine(ln));
}
if (state.shouldAutoScroll) l.scrollTop = l.scrollHeight;
}
} catch (e) {}
}
function renderLine(txt) {
const div = document.createElement('div');
div.textContent = txt;
if (txt.startsWith('[panel] ')) div.style.color = '#7dd3fc';
else if (txt.startsWith('[playit')) div.style.color = '#c4b5fd';
else if (txt.includes('ERROR') || txt.includes('FATAL')) div.style.color = '#f85149';
else if (txt.includes('Done (')) div.style.color = '#3fb950';
return div;
}
function api(url, data) {
const qs = Object.keys(data || {}).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k])).join('&');
return fetch(url + '?' + qs + '&ts=' + Date.now(), {
method: 'GET',
headers: {'X-Token': token}
}).then(r => r.json().then(d => ({status: r.status, d})));
}
async function sendCmd() {
const v = $('cmd').value.trim();
if (!v) return;
$('cmd').value = '';
return runCommand(v);
}
async function quick(c) { return runCommand(c); }
async function runCommand(c) {
const {status, d} = await api('/api/command', {cmd: c});
$('cmd_result').textContent = (status === 200 ? 'sent: ' : 'error: ') + (d.error || c);
if (status === 401) $('auth').style.display = 'flex';
}
async function doAction(a) {
const {status, d} = await api('/api/action', {action: a});
if (status === 401) { $('auth').style.display = 'flex'; return; }
$('cmd_result').textContent = d.error || a;
}
setInterval(refreshStatus, 3000);
setInterval(refreshLog, 1200);
refreshStatus(); refreshLog();
(async () => {
if (token || sessionStorage.getItem('noauth')) return;
$('auth').style.display = 'flex';
})();
$('cmd').addEventListener('keydown', e => { if (e.key === 'Enter') sendCmd(); });
</script>
</body>
</html>"""
def main():
probe_path = os.path.join(DATA_DIR, ".boot_probe")
try:
prev = int(open(probe_path).read()) if os.path.exists(probe_path) else 0
except ValueError:
prev = 0
controller.boot_probe = prev + 1
try:
with open(probe_path, "w") as f:
f.write(str(controller.boot_probe))
except OSError as e:
controller.push(f"[panel] cannot write to {DATA_DIR}: {e}")
controller.start_playit()
controller.start_java()
httpd = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), Handler)
httpd.daemon_threads = True
stop = threading.Event()
def on_signal(signum, frame):
stop.set()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
server_thread.start()
stop.wait()
httpd.shutdown()
server_thread.join(timeout=5)
sys.stderr.write("[panel] shutting down\n")
controller.graceful()
if __name__ == "__main__":
main()