# ╔══════════════════════════════════════════════════════════════════════════════╗ # ║ 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"

401 Unauthorised

Set WEB_PASSWORD in HF Secrets.

" 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"

Terminal starting...

ttyd not ready yet. Refresh in 2 s.

" 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""" Paper MC Dashboard

🟩 Paper Minecraft {html.escape(mc_ver)}

Auto-updating · bore.pub TCP tunnel · Persistent /data · Aikar G1GC · SHA-256 verified · Rolling backups  🔒 Authenticated

Server Status

{st}

Stage: {html.escape(prog.strip())}

💻 Root Terminal

🌐 Connection Address

Share with players. Port changes on each Space restart.

{html.escape(tunnel.strip())}

📝 Minecraft Console (last 150 lines)

{html.escape(mc_log) if mc_log else "Waiting for server to start…"}

🔧 Daemon Log (last 80 lines)

{html.escape(d_log)}
""".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] [bore] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$DAEMON_LOG"; } while true; do lb "Connecting to bore.pub for port 25565..." bore local 25565 --to bore.pub 2>&1 | \ while IFS= read -r line; do printf '[bore] %s\n' "$line" >> "$DAEMON_LOG" if printf '%s' "$line" | grep -qiE 'listening at bore\.pub:[0-9]+'; then PORT="$(printf '%s' "$line" | grep -oE '[0-9]+$')" if [ -n "$PORT" ]; then { printf 'bore.pub:%s\n\n' "$PORT" printf 'Share with players.\n' printf 'Port changes on each Space restart.\n' } > "$TUNNEL_FILE" lb "Tunnel LIVE at bore.pub:${PORT}" fi fi done lb "bore disconnected. Retrying in 10 s..." printf 'Tunnel reconnecting...\n' > "$TUNNEL_FILE" sleep 10 done BOREEOF # ── backup.sh ───────────────────────────────────────────────────────────────── RUN cat > /app/backup.sh << 'BKEOF' #!/usr/bin/env bash set -uo pipefail BASE="$1"; BACKUP_DIR="$2"; DAEMON_LOG="$3" INTERVAL_MIN="${4:-60}"; KEEP_COUNT="${5:-24}"; MC_STDIN="${6:-}" lb(){ printf '[%s] [backup] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$DAEMON_LOG"; } while true; do sleep "$((INTERVAL_MIN * 60))" TS="$(date -u +%Y%m%dT%H%M%SZ)" ARCHIVE="$BACKUP_DIR/world-${TS}.tar.gz" lb "Backup starting -> $ARCHIVE" if [ -n "$MC_STDIN" ] && [ -p "$MC_STDIN" ]; then printf 'save-all\n' > "$MC_STDIN" || true sleep 3 fi tar -czf "$ARCHIVE" \ -C "$BASE" \ --ignore-failed-read \ --exclude='*.lock' \ world world_nether world_the_end 2>/dev/null || true if [ -f "$ARCHIVE" ]; then lb "Backup complete: $(du -sh "$ARCHIVE" 2>/dev/null | cut -f1)" else lb "WARNING: archive not created (world dirs may not exist yet)" fi mapfile -t ALL < <(ls -1t "$BACKUP_DIR"/world-*.tar.gz 2>/dev/null) TOTAL="${#ALL[@]}" if [ "$TOTAL" -gt "$KEEP_COUNT" ]; then printf '%s\n' "${ALL[@]:$KEEP_COUNT}" | xargs rm -f lb "Rotated $(( TOTAL - KEEP_COUNT )) old backup(s). Retaining ${KEEP_COUNT}." fi done BKEOF # ── entrypoint.sh ───────────────────────────────────────────────────────────── RUN cat > /app/entrypoint.sh << 'ENTEOF' #!/usr/bin/env bash set -uo pipefail ######################################################################## # 0. CONFIGURATION ######################################################################## MC_VERSION='1.21.11' MC_RAM_GB=6 MAX_PLAYERS=20 DIFFICULTY='normal' GAMEMODE='survival' MOTD='A Paper Minecraft Server' SEED='' VIEW_DISTANCE=10 SIMULATION_DISTANCE=8 ONLINE_MODE='false' SPAWN_PROTECTION=16 BACKUP_INTERVAL_MIN=60 BACKUP_KEEP=24 ######################################################################## # 1. DIRECTORY STRUCTURE # paper.jar lives at $BASE/paper.jar — same dir as JVM CWD. ######################################################################## BASE=/data/mc BACKUP_DIR=/data/backups LOG_DIR=/data/logs mkdir -p "$BASE" "$BASE/plugins" "$BACKUP_DIR" "$LOG_DIR" JAR="$BASE/paper.jar" ######################################################################## # 2. LOGGING ######################################################################## DAEMON_LOG="$LOG_DIR/daemon.log" PROGRESS_FILE="$LOG_DIR/progress.txt" TUNNEL_FILE="$LOG_DIR/tunnel.txt" if [ -f "$DAEMON_LOG" ]; then SZ=$(stat -c%s "$DAEMON_LOG" 2>/dev/null || echo 0) [ "$SZ" -gt 52428800 ] && mv "$DAEMON_LOG" "${DAEMON_LOG}.1" || true fi printf 'Initialising...\n' > "$PROGRESS_FILE" printf 'Tunnel starting...\n' > "$TUNNEL_FILE" log(){ local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '[%s] %s\n' "$ts" "$*" | tee -a "$DAEMON_LOG" printf '%s\n' "$*" > "$PROGRESS_FILE" } ######################################################################## # 3. JAR INTEGRITY HELPER ######################################################################## jar_is_valid(){ local f="$1" [ -s "$f" ] || return 1 unzip -t "$f" > /dev/null 2>&1 || return 1 return 0 } ######################################################################## # 4. WEB DASHBOARD (port 7860) — started immediately ######################################################################## python3 /app/web.py & WEB_PID=$! log "Web dashboard started on :7860 (pid=${WEB_PID})" ######################################################################## # 5. ttyd WEB TERMINAL # Listens only on 127.0.0.1:7681 — not exposed directly. # Python web.py proxies /terminal/ → ttyd over HTTP + WebSocket. # No ttyd password: the dashboard Basic-Auth gate is the only # protection, which is sufficient behind HF's HTTPS termination. ######################################################################## ttyd \ --port 7681 \ --interface 127.0.0.1 \ --writable \ --cwd /data/mc \ bash & TTYD_PID=$! log "ttyd root terminal started on 127.0.0.1:7681 (pid=${TTYD_PID}), proxied at /terminal/" ######################################################################## # 6. AUTO-UPDATE — PaperMC Fill v3 API # # GET /v3/projects/paper/versions/{ver}/builds # Array is newest-first. .[0] = latest build. # SHA-256 path: .downloads."server:default".checksums.sha256 # # CRITICAL FIX: # After installing a new jar, wipe $BASE/cache/ and $BASE/libraries/. # Paperclip (Paper's bootstrap launcher) extracts inner jars into # cache/ on first run. If those extracts are from a previous corrupt # jar they remain on persistent /data and Paper loads them instead # of re-extracting from the new jar → InternalAPIBridge / # GameRule / BuiltInRegistries cascade failure. # Wiping cache/ forces Paperclip to re-extract cleanly every time # a new build is installed. ######################################################################## log "Checking for latest STABLE Paper ${MC_VERSION}" log "Progress: Checking for Paper update..." API_BUILDS="https://fill.papermc.io/v3/projects/paper/versions/${MC_VERSION}/builds" BUILD_JSON="$( curl -fsSL \ --retry 5 --retry-delay 3 --connect-timeout 20 --max-time 30 \ -H 'User-Agent: HFSpace-PaperServer/5.0' \ "$API_BUILDS" 2>>"$DAEMON_LOG" || true )" if [ -z "$BUILD_JSON" ]; then log "WARNING: PaperMC API unreachable." else LATEST_BUILD="$( printf '%s' "$BUILD_JSON" \ | jq -r 'map(select(.channel=="STABLE")) | .[0].id // empty' \ 2>/dev/null || true )" DOWNLOAD_URL="$( printf '%s' "$BUILD_JSON" \ | jq -r 'map(select(.channel=="STABLE")) | .[0].downloads."server:default".url // empty' \ 2>/dev/null || true )" EXPECTED_SHA="$( printf '%s' "$BUILD_JSON" \ | jq -r 'map(select(.channel=="STABLE")) | .[0].downloads."server:default".checksums.sha256 // empty' \ 2>/dev/null || true )" if [ -z "$LATEST_BUILD" ] || [ -z "$DOWNLOAD_URL" ]; then log "WARNING: Could not parse API response — using cached jar if valid." else log "Latest STABLE build: #${LATEST_BUILD} sha256=$([ -n "$EXPECTED_SHA" ] && echo yes || echo no)" CACHED_BUILD="$(cat /data/.paper_build 2>/dev/null || echo none)" CACHED_VER="$( cat /data/.mc_version 2>/dev/null || echo none)" NEED_DL=0 [ "$LATEST_BUILD" != "$CACHED_BUILD" ] && NEED_DL=1 [ "$MC_VERSION" != "$CACHED_VER" ] && NEED_DL=1 jar_is_valid "$JAR" || NEED_DL=1 if [ "$NEED_DL" -eq 1 ]; then log "Progress: Downloading Paper ${MC_VERSION} build #${LATEST_BUILD}..." TMP_JAR="${JAR}.tmp" rm -f "$TMP_JAR" DL_OK=0 if curl -fL \ --retry 6 --retry-delay 3 --connect-timeout 30 --max-time 300 \ -H 'User-Agent: HFSpace-PaperServer/5.0' \ -o "$TMP_JAR" \ "$DOWNLOAD_URL" 2>>"$DAEMON_LOG"; then SHA_OK=1 if [ -n "$EXPECTED_SHA" ]; then ACTUAL_SHA="$(sha256sum "$TMP_JAR" | awk '{print $1}')" if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then log "FATAL: SHA-256 mismatch!" log " Expected : $EXPECTED_SHA" log " Actual : $ACTUAL_SHA" SHA_OK=0 else log "SHA-256 verified OK." fi fi if [ "$SHA_OK" -eq 1 ]; then if unzip -t "$TMP_JAR" > /dev/null 2>&1; then log "Zip integrity verified OK." mv -f "$TMP_JAR" "$JAR" printf '%s' "$LATEST_BUILD" > /data/.paper_build printf '%s' "$MC_VERSION" > /data/.mc_version # ── CRITICAL: wipe Paperclip's extract cache ────────── # Any previously extracted (possibly corrupt) inner jars # must be removed so Paperclip re-extracts from the new # verified jar. Without this, InternalAPIBridge / # BuiltInRegistries fail from stale cached class files. log "Wiping Paperclip cache to force clean re-extraction..." rm -rf "$BASE/cache" "$BASE/libraries" log "Paper ${MC_VERSION} build #${LATEST_BUILD} installed at ${JAR}" DL_OK=1 else log "FATAL: Downloaded jar failed zip integrity test." fi fi [ "$DL_OK" -eq 0 ] && rm -f "$TMP_JAR" else rm -f "$TMP_JAR" log "WARNING: curl download failed." fi else log "Build #${LATEST_BUILD} cached and valid — skipping download." # Even on cache hit: if cache/ is absent (e.g. first boot after # image rebuild) that is fine — Paperclip will just re-extract. fi fi fi if ! jar_is_valid "$JAR"; then log "FATAL: No valid paper.jar at ${JAR}. Cannot start." exit 1 fi ######################################################################## # 7. SERVER CONFIGURATION (idempotent) ######################################################################## log "Progress: Writing server configuration..." printf 'eula=true\n' > "$BASE/eula.txt" log "eula.txt written to ${BASE}/eula.txt" if [ ! -f "$BASE/server.properties" ]; then log "Creating server.properties (first boot)" cat > "$BASE/server.properties" << PROPS server-port=25565 query.port=25565 enable-query=false online-mode=${ONLINE_MODE} enforce-secure-profile=false prevent-proxy-connections=false max-players=${MAX_PLAYERS} difficulty=${DIFFICULTY} gamemode=${GAMEMODE} force-gamemode=false level-seed=${SEED} level-name=world level-type=minecraft:normal motd=${MOTD} view-distance=${VIEW_DISTANCE} simulation-distance=${SIMULATION_DISTANCE} spawn-protection=${SPAWN_PROTECTION} white-list=false enforce-whitelist=false pvp=true allow-flight=false max-world-size=29999984 network-compression-threshold=256 player-idle-timeout=0 sync-chunk-writes=true enable-rcon=false spawn-monsters=true spawn-animals=true spawn-npcs=true allow-nether=true generate-structures=true PROPS else log "server.properties exists — patching dynamic values only" sed -i \ -e "s|^max-players=.*|max-players=${MAX_PLAYERS}|" \ -e "s|^difficulty=.*|difficulty=${DIFFICULTY}|" \ -e "s|^motd=.*|motd=${MOTD}|" \ -e "s|^view-distance=.*|view-distance=${VIEW_DISTANCE}|" \ -e "s|^simulation-distance=.*|simulation-distance=${SIMULATION_DISTANCE}|" \ "$BASE/server.properties" fi [ -f "$BASE/ops.json" ] || printf '[]\n' > "$BASE/ops.json" [ -f "$BASE/whitelist.json" ] || printf '[]\n' > "$BASE/whitelist.json" ######################################################################## # 8. BORE TCP TUNNEL ######################################################################## log "Starting bore TCP tunnel (bore.pub)" bash /app/bore_tunnel.sh "$TUNNEL_FILE" "$DAEMON_LOG" & BORE_PID=$! log "bore agent started (pid=${BORE_PID})" ######################################################################## # 9. GRACEFUL SHUTDOWN HANDLER ######################################################################## MC_PID='' MC_STDIN='' BACKUP_PID='' _shutdown(){ log "--- Shutdown signal received ---" [ -n "$BACKUP_PID" ] && kill "$BACKUP_PID" 2>/dev/null || true if [ -n "$MC_PID" ] && kill -0 "$MC_PID" 2>/dev/null; then log "Sending stop to Paper..." [ -n "$MC_STDIN" ] && [ -p "$MC_STDIN" ] && printf 'stop\n' > "$MC_STDIN" || true for _i in $(seq 1 60); do kill -0 "$MC_PID" 2>/dev/null || { log "Paper exited cleanly."; break; } sleep 1 done kill -0 "$MC_PID" 2>/dev/null && { log "Force-killing Paper."; kill -9 "$MC_PID"; } fi [ -n "$BORE_PID" ] && kill "$BORE_PID" 2>/dev/null || true [ -n "$TTYD_PID" ] && kill "$TTYD_PID" 2>/dev/null || true log "Shutdown complete." exit 0 } trap '_shutdown' SIGTERM SIGINT SIGHUP ######################################################################## # 10. JVM FLAGS — Aikar G1GC ######################################################################## JVM_FLAGS="\ -Xms${MC_RAM_GB}G \ -Xmx${MC_RAM_GB}G \ -XX:+UseG1GC \ -XX:+ParallelRefProcEnabled \ -XX:MaxGCPauseMillis=200 \ -XX:+UnlockExperimentalVMOptions \ -XX:+DisableExplicitGC \ -XX:+AlwaysPreTouch \ -XX:G1NewSizePercent=30 \ -XX:G1MaxNewSizePercent=40 \ -XX:G1HeapRegionSize=8M \ -XX:G1ReservePercent=20 \ -XX:G1HeapWastePercent=5 \ -XX:G1MixedGCCountTarget=4 \ -XX:InitiatingHeapOccupancyPercent=15 \ -XX:G1MixedGCLiveThresholdPercent=90 \ -XX:G1RSetUpdatingPauseTimePercent=5 \ -XX:SurvivorRatio=32 \ -XX:+PerfDisableSharedMem \ -XX:MaxTenuringThreshold=1 \ -Dusing.aikars.flags=https://mcflags.emc.gs \ -Daikars.new.flags=true \ -Djava.awt.headless=true \ -Dpaper.log-level=INFO" log "JVM: Temurin 21 JDK, heap=${MC_RAM_GB}G, Aikar G1GC" ######################################################################## # 11. WATCHDOG LOOP # # CRITICAL: cd "$BASE" before every java invocation. # Paper resolves eula.txt, server.properties, world/, logs/, # cache/, libraries/ all from the JVM working directory. # # STDIN: open the FIFO for read+write with exec so it stays open # and Paper never receives EOF. This is more reliable than tail -f|java. ######################################################################## RESTART_DELAY=5 RUN=1 while [ "$RUN" -eq 1 ]; do BUILD_NUM="$(cat /data/.paper_build 2>/dev/null || echo unknown)" log "--- Launching Paper ${MC_VERSION} build #${BUILD_NUM} ---" log "Progress: Server starting..." MC_STDIN="/tmp/mc_stdin_$$" rm -f "$MC_STDIN" mkfifo "$MC_STDIN" # Keep the FIFO open by holding an extra write fd; Paper never gets EOF exec 3<>"$MC_STDIN" bash /app/backup.sh \ "$BASE" "$BACKUP_DIR" "$DAEMON_LOG" \ "$BACKUP_INTERVAL_MIN" "$BACKUP_KEEP" "$MC_STDIN" & BACKUP_PID=$! ( cd "$BASE" java $JVM_FLAGS -jar paper.jar --nogui < "$MC_STDIN" ) & MC_PID=$! log "Paper launched (pid=${MC_PID}, cwd=${BASE})" wait "$MC_PID" EXIT_CODE=$? # Close the extra fd and remove the FIFO exec 3>&- rm -f "$MC_STDIN" kill "$BACKUP_PID" 2>/dev/null || true if [ "$EXIT_CODE" -eq 0 ]; then log "Paper exited cleanly (code 0). Watchdog stopping." RUN=0 else log "Paper exited with code ${EXIT_CODE}. Restarting in ${RESTART_DELAY}s..." log "Progress: Server crashed — restarting in ${RESTART_DELAY}s" sleep "$RESTART_DELAY" RESTART_DELAY=$(( RESTART_DELAY * 2 > 120 ? 120 : RESTART_DELAY * 2 )) fi done log "All processes stopped. Container exiting." wait ENTEOF RUN chmod +x /app/entrypoint.sh /app/bore_tunnel.sh /app/backup.sh /app/web.py EXPOSE 7860 CMD ["/app/entrypoint.sh"]