primary / Dockerfile
Nodiw52992's picture
Create Dockerfile
fb3ae0f verified
Raw
History Blame Contribute Delete
33.9 kB
# ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ 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>&#129001; Paper Minecraft {html.escape(mc_ver)}</h1>
<div class="sub">
Auto-updating &middot; bore.pub TCP tunnel &middot; Persistent /data &middot;
Aikar G1GC &middot; SHA-256 verified &middot; Rolling backups
&nbsp;&#128274; <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">&#128187; Root Terminal</a>
</div>
<div class="card">
<h3>&#127760; 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>&#128221; Minecraft Console (last 150 lines)</h3>
<pre>{html.escape(mc_log) if mc_log else "Waiting for server to start&#8230;"}</pre>
</div>
<div class="card">
<h3>&#128295; 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] [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"]