Ntk123 commited on
Commit
4ef5cfe
·
1 Parent(s): 882339a

Add web control panel (console, commands, stop/restart)

Browse files
Files changed (6) hide show
  1. .dockerignore +1 -2
  2. Dockerfile +3 -6
  3. README.md +37 -21
  4. control.py +580 -0
  5. docker-entrypoint.sh +0 -40
  6. index.html +0 -42
.dockerignore CHANGED
@@ -1,5 +1,4 @@
1
  *
2
  !server.jar
3
  !server.properties
4
- !index.html
5
- !docker-entrypoint.sh
 
1
  *
2
  !server.jar
3
  !server.properties
4
+ !control.py
 
Dockerfile CHANGED
@@ -3,7 +3,6 @@ FROM eclipse-temurin:25-jre-jammy
3
  ARG PLAYIT_VERSION=v1.0.10
4
 
5
  ENV MC_MEM=4G \
6
- MC_PORT=25565 \
7
  STATUS_PORT=7860
8
 
9
  RUN apt-get update \
@@ -18,13 +17,11 @@ WORKDIR /data
18
 
19
  COPY server.jar /server/minecraft_server.26.3.jar
20
  COPY server.properties /data/server.properties
21
- COPY index.html /status/index.html
22
- COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
23
 
24
  RUN printf 'eula=true\n' > /data/eula.txt \
25
- && chmod +x /usr/local/bin/docker-entrypoint.sh \
26
  && useradd --create-home --uid 1000 minecraft \
27
- && chown -R minecraft:minecraft /data /status /server
28
 
29
  USER minecraft
30
 
@@ -32,4 +29,4 @@ EXPOSE 25565 7860
32
 
33
  STOPSIGNAL SIGTERM
34
 
35
- ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
 
3
  ARG PLAYIT_VERSION=v1.0.10
4
 
5
  ENV MC_MEM=4G \
 
6
  STATUS_PORT=7860
7
 
8
  RUN apt-get update \
 
17
 
18
  COPY server.jar /server/minecraft_server.26.3.jar
19
  COPY server.properties /data/server.properties
20
+ COPY control.py /server/control.py
 
21
 
22
  RUN printf 'eula=true\n' > /data/eula.txt \
 
23
  && useradd --create-home --uid 1000 minecraft \
24
+ && chown -R minecraft:minecraft /data /server
25
 
26
  USER minecraft
27
 
 
29
 
30
  STOPSIGNAL SIGTERM
31
 
32
+ ENTRYPOINT ["python3", "/server/control.py"]
README.md CHANGED
@@ -11,22 +11,36 @@ pinned: false
11
  # Minecraft Server (Java Edition 26.3)
12
 
13
  Vanilla server bundled with the [playit.gg](https://playit.gg) agent so it can be
14
- reached from a normal Minecraft client even though the Space has no inbound ports.
 
15
 
16
  ## How the networking works
17
 
18
- Hugging Face Spaces only routes HTTPS traffic to `app_port` (7860). A Minecraft
19
- Java client speaks raw TCP, so it cannot connect to the Space directly.
20
-
21
- The playit agent fixes this without any inbound connectivity: it dials **out**
22
- to playit's relays and they proxy the public address back down that same
23
- connection. The Space only ever makes outbound requests.
24
 
25
  ```
26
  Minecraft client ──TCP──> playit relay <──outbound──> playitd ──> java server :25565
27
  ```
28
 
29
- Port 7860 serves a small status page so the Space reports itself as running.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  ## Setup
32
 
@@ -34,20 +48,23 @@ Port 7860 serves a small status page so the Space reports itself as running.
34
  2. Generate a playit **secret key** (link in the playit dashboard, "new agent").
35
  3. Add it in the Space settings under **Variables and secrets** as a **Secret**
36
  named `PLAYIT_SECRET`. Do not commit it.
37
- 4. In the playit dashboard, create a tunnel of type **Minecraft Java** targeting
 
 
38
  `localhost:25565`, then claim it with this Space's agent.
39
- 5. Copy the assigned address and port into the Minecraft client's
40
  *Direct Connect* / *Add Server*.
41
 
42
  ## Configuration
43
 
44
- | Variable | Default | Notes |
45
- | ------------- | ------- | -------------------------------------------- |
46
- | `PLAYIT_SECRET` | unset | Required for the tunnel; server still boots without it |
47
- | `MC_MEM` | `4G` | Both `-Xmx` and `-Xms` |
48
- | `STATUS_PORT` | `7860` | Status page; must match `app_port` |
 
49
 
50
- Server tuning lives in `server.properties`.
51
 
52
  ## Caveats worth knowing
53
 
@@ -57,8 +74,7 @@ Server tuning lives in `server.properties`.
57
  - **Offline mode is on** (`online-mode=false`), so any username can join and
58
  nothing is verified with Mojang. Turn it on if you want real authentication.
59
  - **Java 25 is required.** Minecraft 26.3 ships class file version 69.0, which
60
- Java 21 (65.0) rejects outright with `UnsupportedClassVersionError`. The base
61
- image is pinned to `eclipse-temurin:25-jre-jammy`; bump it first on any
62
- version change.
63
- - The 4G heap needs the 16 GB hardware tier to stay comfortable alongside
64
- Space overhead.
 
11
  # Minecraft Server (Java Edition 26.3)
12
 
13
  Vanilla server bundled with the [playit.gg](https://playit.gg) agent so it can be
14
+ reached from a normal Minecraft client, plus a small web control panel that runs
15
+ in the same container.
16
 
17
  ## How the networking works
18
 
19
+ A Minecraft Java client speaks raw TCP and the Space only routes HTTPS to
20
+ `app_port` (7860), so the two cannot meet directly. The playit agent fixes this
21
+ without any inbound connectivity: it dials **out** to playit's relays and they
22
+ proxy the public address back down that same connection. The Space only ever
23
+ makes outbound requests.
 
24
 
25
  ```
26
  Minecraft client ──TCP──> playit relay <──outbound──> playitd ──> java server :25565
27
  ```
28
 
29
+ Port 7860 hosts the **control panel** (live console, commands, stop/restart),
30
+ which also keeps the Space reporting as running.
31
+
32
+ ## Control panel
33
+
34
+ Open the Space URL in a browser (keep it private if you want - accessing the
35
+ panel is separate from joining the game):
36
+
37
+ - Live server console with auto-scroll
38
+ - Send commands (`say hi`, `list`, `whitelist ...`, `/stop`) via the input or
39
+ quick buttons
40
+ - **Stop** (graceful, saves the world) and **Restart**
41
+ - Status badges: server state, RAM/uptime, playit tunnel state
42
+ - Reading logs/status is open; commands and stop/restart require a password
43
+ when one is configured
44
 
45
  ## Setup
46
 
 
48
  2. Generate a playit **secret key** (link in the playit dashboard, "new agent").
49
  3. Add it in the Space settings under **Variables and secrets** as a **Secret**
50
  named `PLAYIT_SECRET`. Do not commit it.
51
+ 4. (Recommended) Add a second Secret named `CONTROL_PASSWORD` to lock the
52
+ panel's command/stop/restart actions. Leave it unset only while testing.
53
+ 5. In the playit dashboard, create a tunnel of type **Minecraft Java** targeting
54
  `localhost:25565`, then claim it with this Space's agent.
55
+ 6. Copy the assigned address and port into the Minecraft client's
56
  *Direct Connect* / *Add Server*.
57
 
58
  ## Configuration
59
 
60
+ | Variable | Default | Notes |
61
+ | ----------------- | ------- | -------------------------------------------- |
62
+ | `PLAYIT_SECRET` | unset | Required for the tunnel; server still boots without it |
63
+ | `CONTROL_PASSWORD`| unset | Gives the panel Open Console / Stop / Restart a password. Leave unset = unlocked (testing only) |
64
+ | `MC_MEM` | `4G` | Both `-Xmx` and `-Xms` |
65
+ | `STATUS_PORT` | `7860` | Control panel; must match `app_port` |
66
 
67
+ Server tuning lives in `server.properties` aboard the container.
68
 
69
  ## Caveats worth knowing
70
 
 
74
  - **Offline mode is on** (`online-mode=false`), so any username can join and
75
  nothing is verified with Mojang. Turn it on if you want real authentication.
76
  - **Java 25 is required.** Minecraft 26.3 ships class file version 69.0, which
77
+ Java 21 (65.0) rejects with `UnsupportedClassVersionError`. The base image is
78
+ pinned to `eclipse-temurin:25-jre-jammy`; bump it first on any version change.
79
+ - The server auto-restarts on crash (3s backoff) and restarts if the container
80
+ is recreated, so a failed boot loops in the logs - read the newest trace.
 
control.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections import deque
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ DATA_DIR = os.environ.get("MC_DATA", "/data")
16
+ JAR = os.environ.get("MC_JAR", "/server/minecraft_server.26.3.jar")
17
+ MEM = os.environ.get("MC_MEM", "4G")
18
+ HTTP_PORT = int(os.environ.get("STATUS_PORT", "7860"))
19
+ CONTROL_PASSWORD = os.environ.get("CONTROL_PASSWORD", "")
20
+ PLAYIT_SECRET = os.environ.get("PLAYIT_SECRET", "")
21
+ MAX_LOG = 4000
22
+ PLAYIT_TRAIL = 40
23
+
24
+
25
+ class MCController:
26
+ def __init__(self):
27
+ self.lock = threading.RLock()
28
+ self.lines = deque(maxlen=MAX_LOG)
29
+ self.ids = deque(maxlen=MAX_LOG)
30
+ self.next_id = 0
31
+ self.playit_trail = deque(maxlen=PLAYIT_TRAIL)
32
+ self.proc = None
33
+ self.playit = None
34
+ self.stopped = False
35
+ self.pending_restart = False
36
+ self.crashes = 0
37
+ self.started_at = None
38
+ self._kill_timer = None
39
+
40
+ def push(self, text, source="mc"):
41
+ line = str(text)
42
+ with self.lock:
43
+ self.lines.append(line)
44
+ self.ids.append(self.next_id)
45
+ self.next_id += 1
46
+ if source == "playit":
47
+ self.playit_trail.append(line)
48
+
49
+ def log_from(self, after, limit=500):
50
+ with self.lock:
51
+ pairs = list(zip(self.ids, self.lines))
52
+ if after < 0:
53
+ pairs = pairs[-limit:]
54
+ out = [line for _, line in pairs]
55
+ else:
56
+ out = [line for i, line in pairs if i > after][:limit]
57
+ last = pairs[-1][0] if pairs else after
58
+ return out, last
59
+
60
+ def start_playit(self):
61
+ if self.playit is not None and self.playit.poll() is None:
62
+ return
63
+ if not PLAYIT_SECRET:
64
+ self.push("[panel] PLAYIT_SECRET unset - tunnel disabled, server is LAN-only", "playit")
65
+ return
66
+ self.push("[panel] starting playitd", "playit")
67
+ env = dict(os.environ)
68
+ try:
69
+ self.playit = subprocess.Popen(
70
+ ["playitd", "--secret", PLAYIT_SECRET, "--platform-docker"],
71
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
72
+ text=True, encoding="utf-8", errors="replace", env=env,
73
+ )
74
+ except FileNotFoundError:
75
+ self.push("[panel] playitd binary missing - tunnel disabled", "playit")
76
+ self.playit = None
77
+ return
78
+ threading.Thread(target=self._playit_reader, args=(self.playit,), daemon=True).start()
79
+
80
+ def _playit_reader(self, proc):
81
+ for line in proc.stdout:
82
+ self.push(line.rstrip(), "playit")
83
+ code = proc.wait()
84
+ self.push(f"[playit] exited with code {code}", "playit")
85
+
86
+ def playit_state(self):
87
+ if not PLAYIT_SECRET:
88
+ return "disabled"
89
+ p = self.playit
90
+ if p is None:
91
+ return "offline"
92
+ if p.poll() is not None:
93
+ return "offline"
94
+ text = " ".join(self.playit_trail).lower()
95
+ if "connected" in text:
96
+ return "connected"
97
+ if "error" in text or "refused" in text:
98
+ return "error"
99
+ return "connecting"
100
+
101
+ def start_java(self):
102
+ with self.lock:
103
+ if self.proc is not None and self.proc.poll() is None:
104
+ return
105
+ self.proc = None
106
+ if not os.path.exists(os.path.join(DATA_DIR, "eula.txt")):
107
+ self.ensure_conf()
108
+ self.push("[panel] starting Minecraft server")
109
+ env = dict(os.environ)
110
+ args = ["java", "-Xms" + MEM, "-Xmx" + MEM, "-jar", JAR, "nogui"]
111
+ self.proc = subprocess.Popen(
112
+ args, cwd=DATA_DIR,
113
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
114
+ stderr=subprocess.STDOUT,
115
+ text=True, encoding="utf-8", errors="replace", bufsize=1, env=env,
116
+ )
117
+ self.started_at = time.time()
118
+ self._kill_timer = None
119
+ threading.Thread(target=self._mc_reader, args=(self.proc,), daemon=True).start()
120
+
121
+ def ensure_conf(self):
122
+ eula_path = os.path.join(DATA_DIR, "eula.txt")
123
+ props_path = os.path.join(DATA_DIR, "server.properties")
124
+ if not os.path.exists(eula_path):
125
+ with open(eula_path, "w") as f:
126
+ f.write("eula=true\n")
127
+ if not os.path.exists(props_path):
128
+ with open(props_path, "w") as f:
129
+ f.write("server-port=25565\nonline-mode=false\nmax-players=20\nview-distance=4\nsimulation-distance=4\n")
130
+
131
+ def _mc_reader(self, proc):
132
+ for line in proc.stdout:
133
+ self.push(line.rstrip())
134
+ code = proc.wait()
135
+ self._on_mc_exit(code)
136
+
137
+ def _on_mc_exit(self, code):
138
+ with self.lock:
139
+ self.proc = None
140
+ if self.stopped:
141
+ self.push(f"[panel] server stopped (exit {code})")
142
+ return
143
+ self.crashes += 1
144
+ if self.pending_restart:
145
+ self.pending_restart = False
146
+ self.push(f"[panel] restarted by panel (exit {code})")
147
+ restart_delay = 0.0
148
+ else:
149
+ self.push(f"[panel] server exited ({code}) - restarting in 3s")
150
+ restart_delay = 3.0
151
+ if restart_delay <= 0:
152
+ self.start_java()
153
+ else:
154
+ timer = threading.Timer(restart_delay, self.start_java)
155
+ timer.daemon = True
156
+ timer.start()
157
+
158
+ def send_command(self, cmd):
159
+ cmd = str(cmd).strip()
160
+ if not cmd:
161
+ return False, "empty command"
162
+ with self.lock:
163
+ proc = self.proc
164
+ if proc is None or proc.poll() is not None:
165
+ return False, "server is not running"
166
+ try:
167
+ proc.stdin.write((cmd + "\n").encode("utf-8"))
168
+ proc.stdin.flush()
169
+ except (BrokenPipeError, ValueError) as e:
170
+ return False, str(e)
171
+ self.push(f"[panel] > {cmd}")
172
+ return True, "sent"
173
+
174
+ def restart(self):
175
+ with self.lock:
176
+ self.stopped = False
177
+ self.pending_restart = True
178
+ proc = self.proc if self.proc is not None and self.proc.poll() is None else None
179
+ if proc is not None:
180
+ self._arm_kill_timer()
181
+ self._write_stdin(proc, "stop")
182
+ if proc is None:
183
+ self.start_java()
184
+ return "restart requested"
185
+
186
+ def _arm_kill_timer(self):
187
+ def kill():
188
+ with self.lock:
189
+ p = self.proc
190
+ if p is not None and self.pending_restart and p.poll() is None:
191
+ self.push("[panel] force-stopping server")
192
+ p.terminate()
193
+ self._kill_timer = threading.Timer(45.0, kill)
194
+ self._kill_timer.daemon = True
195
+ self._kill_timer.start()
196
+
197
+ def stop(self):
198
+ with self.lock:
199
+ self.stopped = True
200
+ self.pending_restart = False
201
+ proc = self.proc if self.proc is not None and self.proc.poll() is None else None
202
+ if proc is not None:
203
+ self._write_stdin(proc, "stop")
204
+ if proc is None:
205
+ return "server not running"
206
+ return "server stopping"
207
+
208
+ @staticmethod
209
+ def _write_stdin(proc, cmd):
210
+ try:
211
+ proc.stdin.write((cmd + "\n").encode("utf-8"))
212
+ proc.stdin.flush()
213
+ except (BrokenPipeError, ValueError):
214
+ pass
215
+
216
+ def status(self):
217
+ with self.lock:
218
+ proc = self.proc
219
+ proc_alive = proc is not None and proc.poll() is None
220
+ up = int(time.time() - self.started_at) if proc_alive and self.started_at else 0
221
+ rss = self._rss_kb(proc.pid) if proc_alive else 0
222
+ return {
223
+ "mc": "running" if proc_alive else "stopped",
224
+ "uptime": up,
225
+ "crashes": self.crashes,
226
+ "mem_kb": rss,
227
+ "mem_gb": round(rss / (1024 * 1024), 2),
228
+ "playit": self.playit_state(),
229
+ "mem_heap": MEM,
230
+ }
231
+
232
+ @staticmethod
233
+ def _rss_kb(pid):
234
+ try:
235
+ with open(f"/proc/{pid}/status") as f:
236
+ for line in f:
237
+ if line.startswith("VmRSS:"):
238
+ return int(line.split()[1])
239
+ except (OSError, ValueError, IndexError):
240
+ pass
241
+ return 0
242
+
243
+ def graceful(self):
244
+ with self.lock:
245
+ self.stopped = True
246
+ self.pending_restart = False
247
+ p = self.proc
248
+ q = self.playit
249
+ self.push("[panel] shutting down")
250
+ for target in (p, q):
251
+ if target is not None and target.poll() is None:
252
+ try:
253
+ os.kill(target.pid, signal.SIGTERM)
254
+ except OSError:
255
+ pass
256
+ for target, timeout in ((p, 20), (q, 10)):
257
+ if target is not None:
258
+ try:
259
+ target.wait(timeout=timeout)
260
+ except subprocess.TimeoutExpired:
261
+ try:
262
+ target.kill()
263
+ except OSError:
264
+ pass
265
+
266
+
267
+ controller = MCController()
268
+
269
+
270
+ def token_for(password):
271
+ return hashlib.sha256(("panel-control:" + password).encode("utf-8")).hexdigest()
272
+
273
+
274
+ def auth_ok(tok):
275
+ if not CONTROL_PASSWORD:
276
+ return True
277
+ if not tok:
278
+ return False
279
+ return hmac.compare_digest(tok, token_for(CONTROL_PASSWORD))
280
+
281
+
282
+ class Handler(BaseHTTPRequestHandler):
283
+ server_version = "MCPanel/1.0"
284
+
285
+ def _send(self, code, body, ctype):
286
+ if isinstance(body, str):
287
+ body = body.encode("utf-8")
288
+ self.send_response(code)
289
+ self.send_header("Content-Type", ctype)
290
+ self.send_header("Content-Length", str(len(body)))
291
+ self.send_header("Cache-Control", "no-store")
292
+ self.end_headers()
293
+ self.wfile.write(body)
294
+
295
+ def _json(self, code, obj):
296
+ self._send(code, json.dumps(obj, separators=(",", ":")), "application/json")
297
+
298
+ def _read_json(self):
299
+ try:
300
+ n = int(self.headers.get("Content-Length") or 0)
301
+ if n <= 0 or n > 1 << 20:
302
+ return None
303
+ return json.loads(self.rfile.read(n).decode("utf-8"))
304
+ except (ValueError, json.JSONDecodeError):
305
+ return None
306
+
307
+ def log_message(self, fmt, *args):
308
+ pass
309
+
310
+ def do_GET(self):
311
+ u = urlparse(self.path)
312
+ path = u.path
313
+
314
+ if path == "/api/status":
315
+ self._json(200, controller.status())
316
+ elif path == "/api/log":
317
+ q = parse_qs(u.query)
318
+ try:
319
+ after = int(q.get("after", ["-1"])[0])
320
+ limit = int(q.get("limit", ["500"])[0])
321
+ except ValueError:
322
+ after, limit = -1, 500
323
+ lines, last = controller.log_from(after, limit)
324
+ self._json(200, {"lines": lines, "last": last})
325
+ else:
326
+ self._send(200, PAGE, "text/html; charset=utf-8")
327
+
328
+ def do_POST(self):
329
+ u = urlparse(self.path)
330
+ body = self._read_json() or {}
331
+ if u.path == "/api/login":
332
+ if CONTROL_PASSWORD and body.get("password") == CONTROL_PASSWORD:
333
+ self._json(200, {"ok": True, "token": token_for(CONTROL_PASSWORD)})
334
+ elif CONTROL_PASSWORD:
335
+ self._json(401, {"ok": False, "error": "wrong password"})
336
+ else:
337
+ self._json(200, {"ok": True, "token": ""})
338
+ return
339
+
340
+ if u.path == "/api/command":
341
+ if not auth_ok(self.headers.get("X-Token", "")):
342
+ self._json(401, {"ok": False, "error": "auth required"})
343
+ return
344
+ ok, msg = controller.send_command(body.get("cmd", ""))
345
+ self._json(200 if ok else 400, {"ok": ok, "error": msg})
346
+ elif u.path == "/api/action":
347
+ if not auth_ok(self.headers.get("X-Token", "")):
348
+ self._json(401, {"ok": False, "error": "auth required"})
349
+ return
350
+ action = body.get("action")
351
+ if action == "restart":
352
+ self._json(200, {"ok": True, "error": controller.restart()})
353
+ elif action == "stop":
354
+ self._json(200, {"ok": True, "error": controller.stop()})
355
+ else:
356
+ self._json(400, {"ok": False, "error": "unknown action"})
357
+ else:
358
+ self._json(404, {"ok": False, "error": "not found"})
359
+
360
+
361
+ PAGE = r"""<!DOCTYPE html>
362
+ <html lang="en">
363
+ <head>
364
+ <meta charset="utf-8">
365
+ <meta name="viewport" content="width=device-width, initial-scale=1">
366
+ <title>Minecraft Server Control</title>
367
+ <style>
368
+ :root{color-scheme:dark}
369
+ *{box-sizing:border-box}
370
+ body{margin:0;background:#101318;color:#e8e8e8;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:14px}
371
+ header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid #262c35;background:#171b21;position:sticky;top:0}
372
+ h1{font-size:15px;margin:0;font-weight:600}
373
+ .badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid #2b313a;color:#9aa4b0}
374
+ .badge b{color:#e8e8e8}
375
+ .pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:baseline}
376
+ .ok{background:#3fb950}
377
+ .warn{background:#d29922}
378
+ .err{background:#f85149}
379
+ .off{background:#595f66}
380
+ .controls{display:flex;align-items:center;gap:8px;color:#9aa4b0;font-size:12px;margin-left:auto}
381
+ button{background:#242a33;color:#e8e8e8;border:1px solid #333b47;border-radius:6px;padding:6px 12px;font-family:inherit;font-size:12px;cursor:pointer}
382
+ button:hover:not(:disabled){background:#2d3540}
383
+ button[data-danger]{background:#3a1f22;border-color:#6b2a30}
384
+ button:disabled{opacity:.5;cursor:not-allowed}
385
+ main{display:flex;flex-direction:column}
386
+ #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}
387
+ .cmdline{display:flex;gap:8px;padding:10px 16px;border-top:1px solid #262c35;background:#171b21}
388
+ .cmdline input{flex:1;background:#0d0f12;border:1px solid #333b47;color:#e8e8e8;border-radius:6px;padding:9px 12px;font-family:inherit}
389
+ .quick{display:flex;gap:6px;padding:0 16px 10px;flex-wrap:wrap}
390
+ .quick button{padding:4px 9px;font-size:11px;color:#9aa4b0}
391
+ .faint{color:#7d8792}
392
+ #auth{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(5,7,9,.82);z-index:5}
393
+ #auth .card{background:#171b21;border:1px solid #2b313a;padding:22px;border-radius:10px;display:flex;flex-direction:column;gap:10px}
394
+ #auth input,#auth button{font-family:inherit;padding:8px 10px;border-radius:6px;border:1px solid #333b47}
395
+ .row{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid #1c2128;font-size:12px;color:#9aa4b0}
396
+ .row span b{color:#e8e8e8;font-weight:600}
397
+ </style>
398
+ </head>
399
+ <body>
400
+ <div id="auth"><div class="card">
401
+ <div style="font-size:14px">Enter control password</div>
402
+ <input id="pw" type="password" placeholder="password" autocomplete="current-password">
403
+ <button onclick="doLogin()">Unlock</button>
404
+ <div id="pw_err" class="err-row" style="font-size:12px">&nbsp;</div>
405
+ </div></div>
406
+
407
+ <header>
408
+ <h1>Minecraft Server</h1>
409
+ <span class="badge">MC <b id="mc_state">?</b></span>
410
+ <span class="badge">Tunnel <b id="tun_state">?</b></span>
411
+ <span class="badge">Heap <b id="heap_badge">?</b></span>
412
+ <span class="badge">RAM <b id="ram_badge">?</b></span>
413
+ <span class="controls">uptime <b id="uptime"></b> &middot; crashes <b id="crashes"></b></span>
414
+ </header>
415
+
416
+ <div class="row">
417
+ <span>Command: <b id="cmd_result" class="faint">&nbsp;</b></span>
418
+ </div>
419
+
420
+ <pre id="logbox"></pre>
421
+
422
+ <div class="quick">
423
+ <button onclick="quick('list')">list</button>
424
+ <button onclick="quick('help')">help</button>
425
+ <button onclick="quick('save-all')">save-all</button>
426
+ <button onclick="quick('say test 123')">say hi</button>
427
+ <button onclick="quick('whitelist list')">whitelist list</button>
428
+ <button onclick="quick('seed')">seed</button>
429
+ <button onclick="quick('tps')">tps</button>
430
+ <button style="margin-left:auto" data-danger onclick="doAction('stop')">Stop</button>
431
+ <button data-danger onclick="doAction('restart')">Restart</button>
432
+ </div>
433
+
434
+ <div class="cmdline">
435
+ <input id="cmd" placeholder="send a server command (e.g. say hello)" autocomplete="off">
436
+ <button onclick="sendCmd()">Send</button>
437
+ </div>
438
+
439
+ <script>
440
+ let token = sessionStorage.getItem('tok') || '';
441
+ let lastId = -1;
442
+ const $ = id => document.getElementById(id);
443
+ const state = { shouldAutoScroll: true };
444
+
445
+ function setBadge(id, text, cls) {
446
+ const el = $(id);
447
+ el.innerHTML = '<i class="pill ' + (cls || 'off') + '"></i>' + text;
448
+ }
449
+
450
+ $('logbox').addEventListener('scroll', () => {
451
+ const l = $('logbox');
452
+ state.shouldAutoScroll = (l.scrollTop + l.clientHeight) >= l.scrollHeight - 40;
453
+ });
454
+
455
+ function doLogin() {
456
+ fetch('/api/login', {
457
+ method: 'POST',
458
+ headers: {'Content-Type': 'application/json'},
459
+ body: JSON.stringify({password: $('pw').value})
460
+ }).then(r => {
461
+ if (r.status === 401) { $('pw_err').textContent = 'wrong password'; throw 0; }
462
+ return r.json();
463
+ }).then(d => {
464
+ token = d.token || '';
465
+ sessionStorage.setItem('tok', token);
466
+ if (!token) sessionStorage.setItem('noauth', '1');
467
+ $('auth').style.display = 'none';
468
+ }).catch(() => {});
469
+ }
470
+
471
+ async function refreshStatus() {
472
+ try {
473
+ const s = await (await fetch('/api/status?ts=' + Date.now())).json();
474
+ setBadge('mc_state', s.mc, s.mc === 'running' ? 'ok' : 'err');
475
+ const cls = s.playit === 'connected' ? 'ok' : (s.playit === 'disabled' || s.playit === 'offline' ? 'off' : 'warn');
476
+ setBadge('tun_state', s.playit, cls);
477
+ $('heap_badge').textContent = s.mem_heap;
478
+ $('ram_badge').textContent = s.mem_gb + ' GB';
479
+ $('uptime').textContent = fmtUptime(s.uptime);
480
+ $('crashes').textContent = s.crashes;
481
+ } catch (e) {}
482
+ }
483
+ function fmtUptime(sec) {
484
+ const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
485
+ return (h ? h + 'h ' : '') + m + 'm ' + s + 's';
486
+ }
487
+
488
+ async function refreshLog() {
489
+ try {
490
+ const d = await (await fetch('/api/log?after=' + lastId + '&ts=' + Date.now())).json();
491
+ lastId = d.last;
492
+ if (d.lines.length) {
493
+ const l = $('logbox');
494
+ for (const ln of d.lines) {
495
+ l.appendChild(renderLine(ln));
496
+ }
497
+ if (state.shouldAutoScroll) l.scrollTop = l.scrollHeight;
498
+ }
499
+ } catch (e) {}
500
+ }
501
+
502
+ function renderLine(txt) {
503
+ const div = document.createElement('div');
504
+ div.textContent = txt;
505
+ if (txt.startsWith('[panel] ')) div.style.color = '#7dd3fc';
506
+ else if (txt.startsWith('[playit')) div.style.color = '#c4b5fd';
507
+ else if (txt.includes('ERROR') || txt.includes('FATAL')) div.style.color = '#f85149';
508
+ else if (txt.includes('Done (')) div.style.color = '#3fb950';
509
+ return div;
510
+ }
511
+
512
+ function api(url, body) {
513
+ return fetch(url, {
514
+ method: 'POST',
515
+ headers: {'Content-Type': 'application/json', 'X-Token': token},
516
+ body: JSON.stringify(body || {})
517
+ }).then(r => r.json().then(d => ({status: r.status, d})));
518
+ }
519
+
520
+ async function sendCmd() {
521
+ const v = $('cmd').value.trim();
522
+ if (!v) return;
523
+ $('cmd').value = '';
524
+ return runCommand(v);
525
+ }
526
+ async function quick(c) { return runCommand(c); }
527
+ async function runCommand(c) {
528
+ const {status, d} = await api('/api/command', {cmd: c});
529
+ $('cmd_result').textContent = (status === 200 ? 'sent: ' : 'error: ') + (d.error || c);
530
+ if (status === 401) $('auth').style.display = 'flex';
531
+ }
532
+ async function doAction(a) {
533
+ const {status, d} = await api('/api/action', {action: a});
534
+ if (status === 401) { $('auth').style.display = 'flex'; return; }
535
+ $('cmd_result').textContent = d.error || a;
536
+ }
537
+
538
+ setInterval(refreshStatus, 3000);
539
+ setInterval(refreshLog, 1200);
540
+ refreshStatus(); refreshLog();
541
+
542
+ (async () => {
543
+ if (token || sessionStorage.getItem('noauth')) return;
544
+ $('auth').style.display = 'flex';
545
+ })();
546
+
547
+ $('cmd').addEventListener('keydown', e => { if (e.key === 'Enter') sendCmd(); });
548
+ </script>
549
+ </body>
550
+ </html>"""
551
+
552
+
553
+ def main():
554
+ controller.start_playit()
555
+ controller.start_java()
556
+
557
+ httpd = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), Handler)
558
+ httpd.daemon_threads = True
559
+
560
+ stop = threading.Event()
561
+
562
+ def on_signal(signum, frame):
563
+ stop.set()
564
+
565
+ signal.signal(signal.SIGTERM, on_signal)
566
+ signal.signal(signal.SIGINT, on_signal)
567
+
568
+ server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
569
+ server_thread.start()
570
+
571
+ stop.wait()
572
+ httpd.shutdown()
573
+ server_thread.join(timeout=5)
574
+
575
+ sys.stderr.write("[panel] shutting down\n")
576
+ controller.graceful()
577
+
578
+
579
+ if __name__ == "__main__":
580
+ main()
docker-entrypoint.sh DELETED
@@ -1,40 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- pids=()
5
-
6
- shutdown() {
7
- trap - TERM INT
8
- for pid in "${pids[@]}"; do
9
- kill -TERM "$pid" 2>/dev/null || true
10
- done
11
- for pid in "${pids[@]}"; do
12
- wait "$pid" 2>/dev/null || true
13
- done
14
- }
15
-
16
- trap shutdown TERM INT
17
-
18
- python3 -m http.server "$STATUS_PORT" --bind 0.0.0.0 --directory /status &
19
- pids+=("$!")
20
-
21
- if [ -n "${PLAYIT_SECRET:-}" ]; then
22
- playitd --secret "$PLAYIT_SECRET" --platform-docker &
23
- pids+=("$!")
24
- else
25
- echo "PLAYIT_SECRET is unset - tunnel disabled, server is LAN-only" >&2
26
- fi
27
-
28
- java -Xmx"$MC_MEM" -Xms"$MC_MEM" -jar /server/minecraft_server.26.3.jar nogui &
29
- pids+=("$!")
30
-
31
- while true; do
32
- for pid in "${pids[@]}"; do
33
- if ! kill -0 "$pid" 2>/dev/null; then
34
- echo "child $pid exited, tearing down" >&2
35
- shutdown
36
- exit 1
37
- fi
38
- done
39
- sleep 5
40
- done
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html DELETED
@@ -1,42 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Minecraft Server</title>
7
- <style>
8
- :root { color-scheme: dark; }
9
- body { margin: 0; min-height: 100vh; display: grid; place-items: center;
10
- background: #14161a; color: #e6e6e6;
11
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
12
- main { width: min(34rem, 90vw); text-align: center; }
13
- h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
14
- p.sub { margin: 0 0 2rem; color: #8b949e; font-size: .9rem; }
15
- .box { background: #1c2027; border: 1px solid #2b313a; border-radius: .6rem;
16
- padding: 1.25rem; text-align: left; }
17
- .row { display: flex; justify-content: space-between; gap: 1rem;
18
- padding: .45rem 0; font-size: .9rem; }
19
- .row + .row { border-top: 1px solid #262c35; }
20
- .row span:first-child { color: #8b949e; }
21
- code { background: #11141a; padding: .15rem .4rem; border-radius: .3rem;
22
- font-size: .85em; word-break: break-all; }
23
- .dot { display: inline-block; width: .5rem; height: .5rem; border-radius: 50%;
24
- background: #3fb950; margin-right: .4rem; vertical-align: middle; }
25
- footer { margin-top: 1.5rem; font-size: .75rem; color: #6e7681; }
26
- </style>
27
- </head>
28
- <body>
29
- <main>
30
- <h1>Minecraft Server</h1>
31
- <p class="sub">Java Edition, running on a Hugging Face Space</p>
32
- <div class="box">
33
- <div class="row"><span>Status</span><span><i class="dot"></i>online</span></div>
34
- <div class="row"><span>Version</span><span>26.3</span></div>
35
- <div class="row"><span>Java port</span><code>25565</code></div>
36
- <div class="row"><span>Heap</span><code>4G</code></div>
37
- <div class="row"><span>Auth</span><span>offline mode</span></div>
38
- </div>
39
- <footer>Tunnel address is set in your playit.gg dashboard and is not baked into this image.</footer>
40
- </main>
41
- </body>
42
- </html>