Spaces:
Paused
Paused
| # ╔══════════════════════════════════════════════════════════════════════════════╗ | |
| # ║ PRODUCTION PAPER MINECRAFT 1.21.x — HUGGING FACE SPACES ║ | |
| # ╠══════════════════════════════════════════════════════════════════════════════╣ | |
| # ║ Fixes in this version ║ | |
| # ║ ───────────────────────────────────────────────────────────────────────── ║ | |
| # ║ 1. INTERNALAPIBRIDGE / GAMERULE / BUILTINREGISTRIES CRASH ║ | |
| # ║ Root cause: Paperclip extracts nested jars into $BASE/cache/ on first ║ | |
| # ║ run. Previous runs used a corrupt jar → corrupt cached extracts remain ║ | |
| # ║ on /data even after paper.jar is replaced. Paper loads from cache/ ║ | |
| # ║ preferentially, so the new valid jar made no difference. ║ | |
| # ║ Fix: rm -rf $BASE/cache $BASE/libraries whenever a new build is ║ | |
| # ║ installed. Paperclip will re-extract cleanly from the verified jar. ║ | |
| # ║ ║ | |
| # ║ 2. ROOT WEB TERMINAL (ttyd) ║ | |
| # ║ ttyd 1.7.7 static binary installed at build time. ║ | |
| # ║ Runs internally on 127.0.0.1:7681 (bash, writable, no password). ║ | |
| # ║ The Python web server proxies it at /terminal/ (HTTP) and /ws (WS) ║ | |
| # ║ so it is accessible on the single exposed HF port 7860. ║ | |
| # ║ Protected by the same Basic-Auth gate as the rest of the dashboard. ║ | |
| # ║ ║ | |
| # ║ 3. STDIN PIPE — replaced tail -f|java with exec 3<>fifo approach ║ | |
| # ║ More reliable; Paper gets a proper bidirectional STDIN fd. ║ | |
| # ╠══════════════════════════════════════════════════════════════════════════════╣ | |
| # ║ Port 7860 → Dashboard + /terminal/ web shell (Basic-Auth gated) ║ | |
| # ║ Port 25565 → Paper Minecraft → bore.pub TCP tunnel ║ | |
| # ║ HF Secret → WEB_PASSWORD (fallback: "minecraft") ║ | |
| # ╚══════════════════════════════════════════════════════════════════════════════╝ | |
| FROM eclipse-temurin:21-jdk-jammy | |
| LABEL org.opencontainers.image.title="Paper Minecraft 1.21.x · HF Spaces" \ | |
| org.opencontainers.image.description="Auto-updating Paper. Stale-cache fix. bore tunnel. ttyd root terminal. Basic-Auth dashboard. Aikar G1GC." \ | |
| org.opencontainers.image.licenses="MIT" | |
| # ── System packages ─────────────────────────────────────────────────────────── | |
| RUN apt-get update && \ | |
| apt-get install -y --no-install-recommends \ | |
| bash \ | |
| curl \ | |
| jq \ | |
| ca-certificates \ | |
| coreutils \ | |
| findutils \ | |
| procps \ | |
| net-tools \ | |
| tar \ | |
| gzip \ | |
| unzip \ | |
| python3 && \ | |
| rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* | |
| # ── bore static binary ──────────────────────────────────────────────────────── | |
| ARG BORE_RELEASE=0.5.0 | |
| RUN curl -fsSL \ | |
| "https://github.com/ekzhang/bore/releases/download/v${BORE_RELEASE}/bore-v${BORE_RELEASE}-x86_64-unknown-linux-musl.tar.gz" \ | |
| -o /tmp/bore.tar.gz && \ | |
| tar -xzf /tmp/bore.tar.gz -C /usr/local/bin bore && \ | |
| chmod +x /usr/local/bin/bore && \ | |
| rm /tmp/bore.tar.gz && \ | |
| bore --version | |
| # ── ttyd static binary (web terminal) ──────────────────────────────────────── | |
| # Single static binary from the official GitHub release. | |
| # Runs bash as root on 127.0.0.1:7681; proxied through the Python dashboard | |
| # at /terminal/ so it is reachable on the single HF-exposed port 7860. | |
| RUN curl -fsSL \ | |
| "https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64" \ | |
| -o /usr/local/bin/ttyd && \ | |
| chmod +x /usr/local/bin/ttyd && \ | |
| ttyd --version | |
| RUN mkdir -p /app | |
| WORKDIR /app | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # All scripts in RUN layers — Docker parser never sees their contents. | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ── web.py ──────────────────────────────────────────────────────────────────── | |
| # Serves the dashboard AND proxies ttyd (HTTP + WebSocket) at /terminal/ | |
| # Both protected by the same Basic-Auth gate. | |
| RUN cat > /app/web.py << 'PYEOF' | |
| """ | |
| Paper MC dashboard + ttyd terminal proxy — port 7860. | |
| HTTP Basic Auth on every route: username=admin, password=$WEB_PASSWORD. | |
| Routes: | |
| GET / → dashboard (auto-refresh 3 s) | |
| GET /terminal/ → ttyd HTML shell (full root bash) | |
| ALL /terminal/* → HTTP proxy → 127.0.0.1:7681 | |
| WS /terminal/ws → WebSocket proxy → 127.0.0.1:7681/ws | |
| """ | |
| import os, html, base64, socket, threading, time, select | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.request import urlopen, Request | |
| from urllib.error import URLError | |
| LOG_DIR = "/data/logs" | |
| MC_LOG = "/data/mc/logs/latest.log" | |
| PROGRESS = os.path.join(LOG_DIR, "progress.txt") | |
| TUNNEL = os.path.join(LOG_DIR, "tunnel.txt") | |
| DAEMON_LOG = os.path.join(LOG_DIR, "daemon.log") | |
| TTYD_HOST = "127.0.0.1" | |
| TTYD_PORT = 7681 | |
| WEB_USER = "admin" | |
| WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "minecraft") | |
| _REALM = "Paper MC" | |
| _EXPECTED = base64.b64encode(f"{WEB_USER}:{WEB_PASSWORD}".encode()).decode() | |
| def _read(path, tail=0): | |
| try: | |
| with open(path, "r", encoding="utf-8", errors="ignore") as fh: | |
| lines = fh.readlines() | |
| return "".join(lines[-tail:] if tail else lines) | |
| except Exception: | |
| return "" | |
| CSS = """ | |
| *{box-sizing:border-box} | |
| body{font-family:"Segoe UI",Arial,sans-serif;background:#0d1117;color:#c9d1d9;margin:0;padding:0} | |
| .wrap{max-width:1280px;margin:auto;padding:24px 20px} | |
| h1{color:#58a6ff;margin:0 0 4px} | |
| .sub{color:#8b949e;font-size:.88em;margin-bottom:20px} | |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px} | |
| .card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px} | |
| .card h3{margin:0 0 10px;color:#58a6ff;font-size:.9em;text-transform:uppercase;letter-spacing:.06em} | |
| pre{white-space:pre-wrap;word-break:break-all;background:#010409;padding:12px; | |
| border-radius:6px;font-size:.76em;max-height:460px;overflow-y:auto;margin:0;line-height:1.45} | |
| .badge{display:inline-block;padding:3px 12px;border-radius:12px;font-weight:700;font-size:.83em} | |
| .green{background:#1a7f37;color:#fff}.yellow{background:#9e6a03;color:#fff}.red{background:#b91c1c;color:#fff} | |
| .addr{font-family:monospace;font-size:1.05em;color:#79c0ff;background:#010409; | |
| padding:8px 12px;border-radius:6px;display:block;margin-top:6px;word-break:break-all} | |
| .term-btn{display:inline-block;margin-top:10px;padding:8px 18px;background:#1158c7;color:#fff; | |
| border-radius:6px;text-decoration:none;font-weight:700;font-size:.9em} | |
| .term-btn:hover{background:#388bfd} | |
| @media(max-width:760px){.row{grid-template-columns:1fr}} | |
| """ | |
| def _ws_proxy(client_sock, ttyd_host, ttyd_port, path, req_headers): | |
| """Bidirectional raw-socket WebSocket proxy to ttyd.""" | |
| try: | |
| srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| srv.connect((ttyd_host, ttyd_port)) | |
| # Forward the upgrade request verbatim | |
| lines = [f"GET {path} HTTP/1.1\r\n"] | |
| for k, v in req_headers.items(): | |
| # Rewrite Host to point at ttyd | |
| if k.lower() == "host": | |
| lines.append(f"Host: {ttyd_host}:{ttyd_port}\r\n") | |
| else: | |
| lines.append(f"{k}: {v}\r\n") | |
| lines.append("\r\n") | |
| srv.sendall("".join(lines).encode()) | |
| # Relay bytes in both directions until one side closes | |
| socks = [client_sock, srv] | |
| while True: | |
| r, _, e = select.select(socks, [], socks, 30) | |
| if e: | |
| break | |
| if not r: | |
| continue | |
| for s in r: | |
| other = srv if s is client_sock else client_sock | |
| try: | |
| data = s.recv(65536) | |
| except Exception: | |
| data = b"" | |
| if not data: | |
| return | |
| try: | |
| other.sendall(data) | |
| except Exception: | |
| return | |
| finally: | |
| for s in (client_sock, srv if 'srv' in dir() else None): | |
| try: | |
| if s: | |
| s.close() | |
| except Exception: | |
| pass | |
| class H(BaseHTTPRequestHandler): | |
| def _authorised(self): | |
| auth = self.headers.get("Authorization", "") | |
| return auth.startswith("Basic ") and auth[6:].strip() == _EXPECTED | |
| def _challenge(self): | |
| body = b"<h2>401 Unauthorised</h2><p>Set WEB_PASSWORD in HF Secrets.</p>" | |
| self.send_response(401) | |
| self.send_header("WWW-Authenticate", f'Basic realm="{_REALM}"') | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| # ── WebSocket upgrade detection + proxy ─────────────────────────────────── | |
| def _is_ws_upgrade(self): | |
| return (self.headers.get("Upgrade", "").lower() == "websocket" and | |
| self.path.startswith("/terminal/")) | |
| def _handle_ws(self): | |
| """Hijack the raw socket and proxy to ttyd.""" | |
| ttyd_path = self.path[len("/terminal"):] # strip /terminal prefix | |
| if not ttyd_path: | |
| ttyd_path = "/" | |
| # Send 100-continue equivalent: do NOT send 200 yet — let ttyd respond | |
| raw_sock = self.connection | |
| # Detach from the HTTP server's bookkeeping | |
| self.connection = None | |
| t = threading.Thread( | |
| target=_ws_proxy, | |
| args=(raw_sock, TTYD_HOST, TTYD_PORT, ttyd_path, dict(self.headers)), | |
| daemon=True | |
| ) | |
| t.start() | |
| # ── HTTP proxy to ttyd ──────────────────────────────────────────────────── | |
| def _proxy_ttyd(self): | |
| ttyd_path = self.path[len("/terminal"):] | |
| if not ttyd_path: | |
| ttyd_path = "/" | |
| url = f"http://{TTYD_HOST}:{TTYD_PORT}{ttyd_path}" | |
| try: | |
| req = Request(url, headers={ | |
| k: v for k, v in self.headers.items() | |
| if k.lower() not in ("host", "connection") | |
| }) | |
| with urlopen(req, timeout=10) as resp: | |
| body = resp.read() | |
| # Rewrite ttyd's absolute asset URLs to be relative to /terminal/ | |
| content_type = resp.headers.get("Content-Type", "") | |
| if "text/html" in content_type: | |
| body = body.replace(b'src="/', b'src="/terminal/') | |
| body = body.replace(b'href="/', b'href="/terminal/') | |
| # Patch WebSocket URL: ttyd uses ws://host/ws | |
| body = body.replace( | |
| b"location.host", | |
| b'location.host+"/terminal"' | |
| ) | |
| self.send_response(resp.status) | |
| for k, v in resp.headers.items(): | |
| if k.lower() in ("transfer-encoding", "connection"): | |
| continue | |
| self.send_header(k, v) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| except URLError: | |
| self.send_response(502) | |
| msg = b"<h2>Terminal starting...</h2><p>ttyd not ready yet. Refresh in 2 s.</p>" | |
| self.send_header("Content-Type", "text/html") | |
| self.send_header("Content-Length", str(len(msg))) | |
| self.end_headers() | |
| self.wfile.write(msg) | |
| def do_GET(self): | |
| if not self._authorised(): | |
| self._challenge() | |
| return | |
| # WebSocket upgrade for ttyd | |
| if self._is_ws_upgrade(): | |
| self._handle_ws() | |
| return | |
| # HTTP proxy for ttyd assets/API | |
| if self.path.startswith("/terminal/") or self.path == "/terminal": | |
| self._proxy_ttyd() | |
| return | |
| # ── Main dashboard ──────────────────────────────────────────────────── | |
| prog = _read(PROGRESS) or "Initialising..." | |
| tunnel = _read(TUNNEL) or "Tunnel not yet established." | |
| mc_log = _read(MC_LOG, tail=150) | |
| d_log = _read(DAEMON_LOG, tail=80) | |
| is_online = "Done (" in mc_log or "For help, type" in mc_log | |
| is_crashed = "crashed" in prog.lower() or "FATAL" in prog | |
| if is_online: | |
| sc, st = "green", "ONLINE" | |
| elif is_crashed: | |
| sc, st = "red", "CRASHED" | |
| else: | |
| sc, st = "yellow", "STARTING" | |
| mc_ver = "1.21.x" | |
| marker = "Starting minecraft server version " | |
| if marker in mc_log: | |
| mc_ver = mc_log.split(marker)[-1].split("\n")[0].strip() | |
| body = f"""<!doctype html> | |
| <html lang="en"><head><meta charset="utf-8"> | |
| <meta http-equiv="refresh" content="3"> | |
| <title>Paper MC Dashboard</title> | |
| <style>{CSS}</style></head><body> | |
| <div class="wrap"> | |
| <h1>🟩 Paper Minecraft {html.escape(mc_ver)}</h1> | |
| <div class="sub"> | |
| Auto-updating · bore.pub TCP tunnel · Persistent /data · | |
| Aikar G1GC · SHA-256 verified · Rolling backups | |
| 🔒 <i>Authenticated</i> | |
| </div> | |
| <div class="row"> | |
| <div class="card"> | |
| <h3>Server Status</h3> | |
| <p><span class="badge {sc}">{st}</span></p> | |
| <p style="margin:8px 0 0;font-size:.9em"> | |
| <b>Stage:</b> {html.escape(prog.strip())}</p> | |
| <a class="term-btn" href="/terminal/" target="_blank">💻 Root Terminal</a> | |
| </div> | |
| <div class="card"> | |
| <h3>🌐 Connection Address</h3> | |
| <p style="font-size:.85em;color:#8b949e;margin:0 0 6px"> | |
| Share with players. Port changes on each Space restart.</p> | |
| <span class="addr">{html.escape(tunnel.strip())}</span> | |
| </div> | |
| </div> | |
| <div class="card" style="margin-bottom:14px"> | |
| <h3>📝 Minecraft Console (last 150 lines)</h3> | |
| <pre>{html.escape(mc_log) if mc_log else "Waiting for server to start…"}</pre> | |
| </div> | |
| <div class="card"> | |
| <h3>🔧 Daemon Log (last 80 lines)</h3> | |
| <pre>{html.escape(d_log)}</pre> | |
| </div> | |
| </div></body></html>""".encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| # ttyd sends POST for some token endpoints | |
| def do_POST(self): | |
| if not self._authorised(): | |
| self._challenge() | |
| return | |
| if self.path.startswith("/terminal/"): | |
| self._proxy_ttyd() | |
| return | |
| self.send_response(404) | |
| self.end_headers() | |
| def log_message(self, *a): | |
| return | |
| if __name__ == "__main__": | |
| ThreadingHTTPServer(("0.0.0.0", 7860), H).serve_forever() | |
| PYEOF | |
| # ── bore_tunnel.sh ──────────────────────────────────────────────────────────── | |
| RUN cat > /app/bore_tunnel.sh << 'BOREEOF' | |
| #!/usr/bin/env bash | |
| set -uo pipefail | |
| TUNNEL_FILE="$1" | |
| DAEMON_LOG="$2" | |
| lb(){ printf '[%s] | |