From 80a29ba6535e5855a3c6f46b537d95fb6780d719 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 21 Jul 2026 00:39:46 +0900 Subject: [PATCH] feat(lobby): ship LuckPerms in the lobby image so the panel's permission controls work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel has a full permission surface — internal/api/handlers_access.go issues `lp user permission set/unset` and `lp user parent add/remove` over RCON, and projects the result back at GET /api/v1/servers/{name}/access/luckperms/{player} — but nothing in this tree ever installed LuckPerms. The lobby image copied felis-paper.jar into the plugin directory and stopped there, so every grant the panel sent reached a server that answered "Unknown command". Confirmed on the demo host: /data/plugins held only FelisPaper/, bStats/, felis-paper.jar and spark/. This is the other half of the RCON change. That one gave the control plane a channel to send commands on; this one puts something at the far end that understands them. Neither is useful alone. The jar is resolved at build time rather than pinned in the Dockerfile, the same way PAPER_JAR_URL already is: metadata.luckperms.net publishes the current build for every platform, and asking upstream keeps this tree from going stale on every LuckPerms release. Unlike Paper it is not version-matched to MC_VERSION — LuckPerms ships one Bukkit build covering the whole supported Minecraft range, so there is no per-version endpoint to ask. The resolver's pattern pins the /bukkit/loader/ path segment deliberately: the metadata endpoint hands back the fabric, forge, velocity and bukkit-legacy URLs in the same payload, and a looser match would happily return a jar Paper cannot load, or the legacy build that targets Minecraft 1.8-1.12. A missing LUCKPERMS_JAR_URL fails the build. That is a harsher default than the RCON password, which only warns, and the difference is where the failure surfaces: a lobby without RCON degrades visibly at once, whereas a lobby without LuckPerms starts perfectly, runs perfectly, and only reveals itself when an owner tries to grant somebody a permission. Build time is the cheap place to notice. The entrypoint refreshes the jar from the image seed on every boot exactly as it does for paper.jar and felis-paper.jar, so the executable artifact tracks the image while LuckPerms' H2 database and config under plugins/LuckPerms/ stay on the PVC. That split is the point: every grant ever issued lives in that directory, so the refresh must never become a wipe. Check: the three files that have to agree about LuckPerms — bootstrap.sh resolving and passing the build-arg, the Dockerfile requiring that arg name and writing a fixed path, the entrypoint copying from that same path — are pinned against each other. Nothing compiles them together, and a typo in the path is invisible until a lobby boots and `set -e` turns the failed cp into a crashloop on the hub every authenticated player is transferred to. The test reads all three back out of the embedded FS rather than off disk, since that is what the TUI install path ships. Deployed installs are NOT fixed by this commit, for the same reason the RCON change was not: the felis-lobby image has to be rebuilt and re-imported, and the pods recreated, before the jar exists on the volume. --- bootstrap_asset_test.go | 74 ++++++++++++++++++++++++++++++++++++++ deploy/bootstrap.sh | 29 +++++++++++++-- deploy/lobby/Dockerfile | 20 +++++++++-- deploy/lobby/entrypoint.sh | 4 +++ 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 bootstrap_asset_test.go diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go new file mode 100644 index 0000000..3f44585 --- /dev/null +++ b/bootstrap_asset_test.go @@ -0,0 +1,74 @@ +package felis + +import ( + "strings" + "testing" +) + +// The lobby's LuckPerms wiring is a three-file contract with no compiler behind it: +// bootstrap.sh resolves a URL and passes it as a build-arg, the Dockerfile requires +// that exact arg name and writes the jar to a fixed path, and entrypoint.sh copies +// from that same path on every boot. A typo in any one of them is invisible until a +// lobby actually starts — and then `set -e` turns the failed cp into a crashloop on +// the always-on hub every authenticated player is transferred to. +// +// All three files ship inside the binary (gameStackAssets, bootstrapScript) for the +// TUI install path that has no source checkout, so reading them back here checks +// what is actually shipped rather than what is merely on disk. +func TestLobbyLuckPermsWiringIsConsistent(t *testing.T) { + dockerfile := readGameStackFile(t, "deploy/lobby/Dockerfile") + entrypoint := readGameStackFile(t, "deploy/lobby/entrypoint.sh") + script := BootstrapScript() + + // The path is the contract. RUNTIME_DIR is /paper in the entrypoint, so the + // Dockerfile's download target and the boot-time copy source must be the same + // string; anything else fails at `cp`, not at build. + const jarPath = "/paper/plugins/LuckPerms.jar" + if !strings.Contains(dockerfile, "-o "+jarPath) { + t.Errorf("Dockerfile does not download LuckPerms to %s", jarPath) + } + if !strings.Contains(entrypoint, `cp -f "$RUNTIME_DIR/plugins/LuckPerms.jar"`) { + t.Error("entrypoint.sh does not refresh LuckPerms.jar from the image seed; " + + "a lobby would keep whatever stale jar the PVC happens to hold") + } + if !strings.Contains(entrypoint, `RUNTIME_DIR="/paper"`) { + t.Error(`RUNTIME_DIR is no longer "/paper", so the copy above no longer ` + + "resolves to the path the Dockerfile writes") + } + + // The build-arg name has to agree across the two files that never see each other. + const arg = "LUCKPERMS_JAR_URL" + if !strings.Contains(dockerfile, "ARG "+arg) { + t.Errorf("Dockerfile declares no ARG %s", arg) + } + if !strings.Contains(script, "--build-arg "+arg+"=") { + t.Errorf("bootstrap.sh never passes --build-arg %s", arg) + } + if !strings.Contains(script, "LUCKPERMS_JAR_URL=\"$(luckperms_latest_jar)\"") { + t.Error("bootstrap.sh does not resolve the LuckPerms URL before building") + } + + // bukkit-legacy targets Minecraft 1.8-1.12 and the other platforms are not + // loadable by Paper at all, so the resolver's grep must pin the /bukkit/ path + // segment — the metadata endpoint returns every platform's URL in one payload. + if !strings.Contains(script, "/bukkit/loader/") { + t.Error("luckperms_latest_jar does not pin the bukkit/loader path; it could " + + "return the fabric, forge or velocity jar, none of which Paper can load") + } + + // A missing jar must stop the build. The alternative — shipping a lobby that + // starts fine and answers every `lp` command from the panel's permission screen + // with "Unknown command" — is discovered in production. + if !strings.Contains(dockerfile, `if [ -z "${LUCKPERMS_JAR_URL:-}" ]`) { + t.Error("Dockerfile does not fail the build when LUCKPERMS_JAR_URL is unset") + } +} + +func readGameStackFile(t *testing.T, name string) string { + t.Helper() + b, err := gameStackAssets.ReadFile(name) + if err != nil { + t.Fatalf("read embedded %s: %v", name, err) + } + return string(b) +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 676b84b..f4c2286 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1181,7 +1181,31 @@ resolve_game_jars() { log "resolving the newest Paper ${MC_VERSION} build" PAPER_JAR_URL="$(papermc_latest_jar paper "$MC_VERSION")" \ || die "could not resolve a Paper build for Minecraft ${MC_VERSION} (the login gate pins this protocol; the build likely exists — Fill upstream is down or flapping)" - ok "Limbo ${LIMBO_VERSION} + Paper, both on Minecraft ${MC_VERSION}" + # LuckPerms is not version-matched to MC_VERSION the way Paper is: it ships one + # current Bukkit build that supports the whole supported Minecraft range, so there is + # no per-version endpoint to ask. + log "resolving the newest LuckPerms build" + LUCKPERMS_JAR_URL="$(luckperms_latest_jar)" \ + || die "could not resolve a LuckPerms build (metadata.luckperms.net is down or flapping); the lobby needs it for the panel's permission controls" + ok "Limbo ${LIMBO_VERSION} + Paper, both on Minecraft ${MC_VERSION}; LuckPerms resolved" +} + +# luckperms_latest_jar prints the download URL of the current LuckPerms Bukkit build. +# Bukkit, not bukkit-legacy: legacy targets Minecraft 1.8-1.12, and Paper 26.2 is far +# past that. The same fetch-then-grep shape (and --retry rationale) as +# papermc_latest_jar; the metadata endpoint hands back every platform's URL at once, so +# the grep has to pin the /bukkit/ path segment or it would just as happily return the +# Fabric or Velocity jar, neither of which Paper can load. +luckperms_latest_jar() { + local json url + json="$(curl -fsSL --retry 5 --retry-delay 2 \ + -A "felis-bootstrap (+https://github.com/MliroLirrorsIngenuity/Felis)" \ + "https://metadata.luckperms.net/data/all")" || return 1 + url="$(printf '%s' "$json" \ + | grep -o 'https://download\.luckperms\.net/[0-9]\{1,\}/bukkit/loader/[^"]*\.jar' || true)" + url="${url%%$'\n'*}" + [ -n "$url" ] || return 1 + printf '%s\n' "$url" } # papermc_latest_jar prints the download URL of the newest build of . @@ -1212,9 +1236,10 @@ build_game_stack() { --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ -t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR" - log "building ${FELIS_LOBBY_IMAGE} (Paper ${MC_VERSION} + felis-paper /menu)" + log "building ${FELIS_LOBBY_IMAGE} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ -t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR" local img diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 74cc56e..2216883 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -1,4 +1,4 @@ -# Felis lobby image: Paper + the felis-paper /menu plugin. +# Felis lobby image: Paper + the felis-paper /menu plugin + LuckPerms. # # CODE-ONLY in this repo — not built by the Go CI. It packages the always-on # "lobby" hub the setup provisioner points [velocity] lobby_image at. The lobby is @@ -9,6 +9,8 @@ # Fill v3 API — api.papermc.io v2 has returned HTTP 410 since 2026-07-01): # docker build -f deploy/lobby/Dockerfile \ # --build-arg PAPER_JAR_URL=https://fill-data.papermc.io/v1/objects//paper-26.2-.jar \ +# --build-arg LUCKPERMS_JAR_URL="$(curl -fsSL https://metadata.luckperms.net/data/all \ +# | grep -o 'https://download.luckperms.net/[^"]*/bukkit/loader/[^"]*\.jar')" \ # -t felis-lobby:demo . # docker save felis-lobby:demo | sudo k3s ctr images import - # # felis.toml → [velocity] lobby_image = "felis-lobby:demo" @@ -40,15 +42,27 @@ RUN cd plugins/paper \ # also runs the plugin's Java-21 bytecode, so only the runtime moves. FROM eclipse-temurin:25-jre ARG PAPER_JAR_URL +# LuckPerms is required, not optional: the panel's whole permission surface +# (internal/api/handlers_access.go) issues `lp user ...` over RCON, so a lobby built +# without it answers every grant with "Unknown command" — a failure the operator only +# discovers in production, because the server itself starts and runs perfectly well. +# Failing the build is the cheap place to notice. Resolved by URL rather than pinned +# here for the same reason PAPER_JAR_URL is: bootstrap.sh asks upstream for the current +# build, so this file does not go stale on every LuckPerms release. +ARG LUCKPERMS_JAR_URL WORKDIR /paper RUN set -eu; \ if [ -z "${PAPER_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg PAPER_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${LUCKPERMS_JAR_URL:-}" ]; then \ + echo "ERROR: --build-arg LUCKPERMS_JAR_URL= is required" >&2; exit 1; \ + fi; \ apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ - curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ - apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ mkdir -p /paper/plugins; \ + curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ + curl -fSL "$LUCKPERMS_JAR_URL" -o /paper/plugins/LuckPerms.jar; \ + apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ echo "eula=true" > /paper/eula.txt COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar # The entrypoint writes the Velocity modern-forwarding config (and REFUSES to start diff --git a/deploy/lobby/entrypoint.sh b/deploy/lobby/entrypoint.sh index 0641ae4..230e6fb 100644 --- a/deploy/lobby/entrypoint.sh +++ b/deploy/lobby/entrypoint.sh @@ -43,6 +43,10 @@ fi mkdir -p "$DATA_DIR/plugins" cp -f "$RUNTIME_DIR/paper.jar" "$DATA_DIR/paper.jar" cp -f "$RUNTIME_DIR/plugins/felis-paper.jar" "$DATA_DIR/plugins/felis-paper.jar" +# Only the jar is refreshed — LuckPerms keeps its H2 database and config under +# $DATA_DIR/plugins/LuckPerms/, which is exactly the state the PVC exists to preserve. +# Every grant the panel has ever issued lives there, so this must never be a wipe. +cp -f "$RUNTIME_DIR/plugins/LuckPerms.jar" "$DATA_DIR/plugins/LuckPerms.jar" printf 'eula=true\n' > "$DATA_DIR/eula.txt" cd "$DATA_DIR"