File size: 26,240 Bytes
4ef5cfe
 
 
 
 
e0b0081
4ef5cfe
2510bce
4ef5cfe
 
 
 
fdbed69
4ef5cfe
 
 
 
2f4bd8e
2510bce
4ef5cfe
e0b0081
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10319cd
 
 
 
 
 
4ef5cfe
 
10319cd
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1816c94
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691f3a3
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0b0081
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
691f3a3
4ef5cfe
 
 
 
 
 
 
 
 
 
2446e8b
4ef5cfe
2446e8b
 
4ef5cfe
 
 
 
 
 
 
2510bce
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2510bce
 
4ef5cfe
2510bce
4ef5cfe
2510bce
 
4ef5cfe
33a046e
2510bce
 
33a046e
 
 
 
 
 
 
 
 
 
 
 
 
2510bce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33a046e
4ef5cfe
 
 
132ab00
 
 
 
 
 
 
4ef5cfe
 
 
132ab00
4ef5cfe
 
 
 
 
132ab00
 
4ef5cfe
 
 
 
132ab00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0b0081
 
132ab00
 
4ef5cfe
 
 
 
132ab00
4ef5cfe
fdbed69
 
 
132ab00
fdbed69
 
 
cab8bfd
 
4ef5cfe
cab8bfd
4ef5cfe
 
 
 
 
 
 
 
 
 
 
cab8bfd
 
4ef5cfe
 
 
 
cab8bfd
4ef5cfe
 
 
 
e0b0081
 
4ef5cfe
 
 
 
 
cab8bfd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cab8bfd
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cab8bfd
132ab00
 
 
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2446e8b
 
 
 
 
 
 
 
 
 
 
 
4ef5cfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
#!/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()