diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go index b352454..e7cec53 100644 --- a/bootstrap_asset_test.go +++ b/bootstrap_asset_test.go @@ -66,6 +66,37 @@ func TestLobbyLuckPermsWiringIsConsistent(t *testing.T) { } } +// The Paper jar digest rides the same cross-file contract as LuckPerms above: bootstrap.sh +// resolves "url sha256" out of Fill's content-addressed download URL and passes the digest +// as a build-arg the Dockerfile must require and verify. docker only WARNS about an unknown +// --build-arg, so a renamed arg would surface as a required-arg failure on a real host +// mid-install — this test is the only compile step the pairing gets. +// +// Both images pull the same jar from the same URL, so both have to check it: a gate on one +// of them leaves the other booting on whatever bytes happened to arrive. +func TestPaperJarDigestWiringIsConsistent(t *testing.T) { + const arg = "PAPER_JAR_SHA256" + if n := strings.Count(BootstrapScript(), "--build-arg "+arg+"="); n < 2 { + t.Errorf("bootstrap.sh passes --build-arg %s %d time(s); the lobby and the "+ + "plain-Paper build each need it", arg, n) + } + for _, name := range []string{"deploy/lobby/Dockerfile", "deploy/paper/Dockerfile"} { + dockerfile := readGameStackFile(t, name) + if !strings.Contains(dockerfile, "ARG "+arg) { + t.Errorf("%s declares no ARG %s", name, arg) + } + if !strings.Contains(dockerfile, `if [ -z "${PAPER_JAR_SHA256:-}" ]`) { + t.Errorf("%s does not fail the build when %s is unset", name, arg) + } + // Requiring the arg is not the same as spending it, and which file gets hashed + // matters as much as the command: a `sha256sum -c` over some other download + // would satisfy a bare substring check while paper.jar still arrives unchecked. + if !strings.Contains(dockerfile, `echo "$PAPER_JAR_SHA256 /paper/paper.jar" | sha256sum -c`) { + t.Errorf("%s never verifies /paper/paper.jar against %s", name, arg) + } + } +} + // A 1.8 client joining a protocol-47 backend dies on the first chunk unless ViaVersion's // serverside block-connection tracking is off: under modern forwarding the Velocity injector // reports 1.13 as the lowest supported protocol, ConnectionData.init() returns early on that, diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index adc3ec1..ac6b478 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1200,7 +1200,7 @@ game_stack_source() { # MC_VERSION is read off Limbo's CI artifact name (Limbo--.jar), which # is the only place the pairing is published. resolve_game_jars() { - local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta file base rest + local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta file base rest paper log "resolving the newest LOOHP/Limbo CI build" # Fetch first, filter second: `curl | grep | head` dies of SIGPIPE under `set -o pipefail` # the moment head closes the pipe early. Same shape everywhere below. @@ -1221,8 +1221,10 @@ resolve_game_jars() { # PaperMC Fill v3. The old api.papermc.io v2 has returned HTTP 410 since 2026-07-01 and # is never coming back; Fill wants a descriptive User-Agent. 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)" + paper="$(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, flapping, or no longer content-addressed)" + PAPER_JAR_URL="${paper% *}" + PAPER_JAR_SHA256="${paper##* }" # 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. @@ -1250,20 +1252,29 @@ luckperms_latest_jar() { printf '%s\n' "$url" } -# papermc_latest_jar prints the download URL of the newest build of . +# papermc_latest_jar prints " " for the newest build of . # --retry rides out Fill's transient gateway errors (502/503/504 are in curl's retry # set): a single blip must not abort the whole bootstrap claiming the build is missing. # Plain --retry only, deliberately: --retry-connrefused needs curl 7.52+, which the yum # (el7) path does not have, and it would only add ECONNREFUSED to an already-covered set. +# The digest is not fished out of the JSON separately: Fill's download URLs are +# content-addressed (/v1/objects//.jar), so the path segment names the +# bytes the URL serves and both halves come from the same grep of the same response. A +# URL without that shape fails the resolve rather than waving the download through +# unchecked. papermc_latest_jar() { - local project="$1" version="$2" json urls url + local project="$1" version="$2" json urls url sha json="$(curl -fsSL --retry 5 --retry-delay 2 \ -A "felis-bootstrap (+https://github.com/MliroLirrorsIngenuity/Felis)" \ "https://fill.papermc.io/v3/projects/${project}/versions/${version}/builds/latest")" || return 1 urls="$(printf '%s' "$json" | grep -o 'https://fill-data\.papermc\.io/[^"]*\.jar' || true)" url="${urls%%$'\n'*}" [ -n "$url" ] || return 1 - printf '%s\n' "$url" + sha="${url#*/objects/}" + sha="${sha%%/*}" + case "$sha" in *[!0-9a-f]*|"") return 1 ;; esac + [ "${#sha}" -eq 64 ] || return 1 + printf '%s %s\n' "$url" "$sha" } build_game_stack() { @@ -1281,6 +1292,7 @@ build_game_stack() { 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 PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ -t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR" @@ -1289,6 +1301,7 @@ build_game_stack() { log "building ${FELIS_PAPER_IMAGE} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ -t "$FELIS_PAPER_IMAGE" "$GAME_STACK_DIR" local img @@ -1486,7 +1499,7 @@ install_jre() { install_velocity() { install_jre - local url tmp have want + local url tmp have want resolved prepare_velocity_layout if [ -n "$FELIS_VELOCITY_FORK_JAR" ]; then [ -f "$FELIS_VELOCITY_FORK_JAR" ] \ @@ -1514,12 +1527,20 @@ install_velocity() { atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root else log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build" - url="$(papermc_latest_jar velocity "$FELIS_VELOCITY_VERSION")" \ + resolved="$(papermc_latest_jar velocity "$FELIS_VELOCITY_VERSION")" \ || die "no Velocity build for ${FELIS_VELOCITY_VERSION} (override with FELIS_VELOCITY_VERSION)" + url="${resolved% *}" + want="${resolved##* }" log "downloading Velocity ${FELIS_VELOCITY_VERSION}" tmp="$(mktemp "${VELOCITY_DIR}/.velocity.jar.XXXXXX")" remember_temp "$tmp" curl -fsSL "$url" -o "$tmp" || die "failed to download Velocity: ${url}" + # The same gate the Via plugins and the fork jar pass: this jar is the proxy every + # player connects through, and Fill already promised its digest in the URL — a + # truncated or tampered download becomes a refusal here, not a proxy that won't boot. + have="$(sha256sum <"$tmp" | cut -d' ' -f1)" + [ "$have" = "$want" ] \ + || die "Velocity ${FELIS_VELOCITY_VERSION} checksum mismatch: got ${have}, expected ${want}" atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar" 0644 root root fi diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index ac06448..9f99306 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -61,6 +61,77 @@ expect "an uppercase digest is the same digest" "LOG: installing the Felis-Legac out="$(run_gate "$(printf '%s' "$want" | sed 's/../& /g')")" expect "a space-separated digest is the same digest" "LOG: installing the Felis-Legacy Velocity fork" "$out" +# --- papermc_latest_jar answers "url sha256" from one response -------------------------- +# Fill's download URLs are content-addressed (/v1/objects//.jar), and the +# resolver's contract is to hand both halves back from the same grep — or refuse a URL +# that carries no digest, rather than wave the download through unchecked. Run under +# bash, not sh: bootstrap.sh is bash and the function uses $'\n'. + +fn="$(awk '/^papermc_latest_jar\(\)/,/^}/' "$BS")" +[ -n "$fn" ] || { echo "FAIL: no papermc_latest_jar in $BS"; exit 1; } +[ "$(printf '%s\n' "$fn" | wc -l)" -lt 30 ] \ + || { echo "FAIL: the extracted papermc_latest_jar is not just the function -- did its closing brace move?"; exit 1; } + +rsha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +run_resolver() { # canned-fill-response + CANNED="$1" bash -c ' + curl() { printf "%s" "$CANNED"; } + '"$fn"' + if out="$(papermc_latest_jar velocity 3.5.1)"; then + printf "RESOLVED %s\n" "$out" + else + printf "REFUSED\n" + fi + ' +} + +out="$(run_resolver "{\"url\":\"https://fill-data.papermc.io/v1/objects/${rsha}/velocity-3.5.1-615.jar\"}")" +expect "the resolver pairs the url with its own digest" \ + "RESOLVED https://fill-data.papermc.io/v1/objects/${rsha}/velocity-3.5.1-615.jar ${rsha}" "$out" + +out="$(run_resolver '{"url":"https://fill-data.papermc.io/mirror/velocity-3.5.1-615.jar"}')" +expect "a URL that carries no digest is refused" "REFUSED" "$out" + +# --- the resolved-Velocity digest gate -------------------------------------------------- +# The download must hash to what the content-addressed URL promised, BEFORE +# atomic_install_file — the same refusal the Via plugins and the fork jar already get. + +# The end pattern spells ${VELOCITY_DIR} with dots: escaped braces are literal in gawk +# and mawk but undefined in POSIX awk, and CI's awk is whatever ubuntu ships. +vblock="$(awk '/log "resolving the newest Velocity/,/atomic_install_file "\$tmp" "\$.VELOCITY_DIR.\/velocity\.jar"/' "$BS")" +[ -n "$vblock" ] || { echo "FAIL: no resolved-Velocity install block found in $BS"; exit 1; } +[ "$(printf '%s\n' "$vblock" | wc -l)" -lt 30 ] \ + || { echo "FAIL: the extracted block is not the velocity install -- did its last line move?"; exit 1; } + +vdir="$(mktemp -d)" +trap 'rm -f "$jar"; rm -rf "$vdir"' EXIT +vwant="$(printf 'stand-in velocity build\n' | sha256sum | cut -d' ' -f1)" + +run_velocity_install() { # digest-the-resolver-reports + WANT="$1" VELOCITY_DIR="$vdir" FELIS_VELOCITY_VERSION=3.5.1 bash -c ' + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { printf "LOG: %s\n" "$*"; } + remember_temp() { :; } + papermc_latest_jar() { + printf "%s %s\n" "https://fill-data.papermc.io/v1/objects/${WANT}/velocity-3.5.1-615.jar" "$WANT" + } + curl() { while [ "$#" -gt 1 ] && [ "$1" != "-o" ]; do shift; done; printf "stand-in velocity build\n" > "$2"; } + atomic_install_file() { printf "INSTALL: %s\n" "$2"; } + '"$vblock" +} + +out="$(run_velocity_install deadbeef)" +expect "a download that does not hash to the promised digest is refused" \ + "DIE: Velocity 3.5.1 checksum mismatch: got ${vwant}, expected deadbeef" "$out" +case "$out" in + *INSTALL:*) echo "FAIL a refused download must not reach atomic_install_file"; fails=$((fails + 1)) ;; + *) echo "PASS a refused download is not installed" ;; +esac + +out="$(run_velocity_install "$vwant")" +expect "the matching download installs" "INSTALL: ${vdir}/velocity.jar" "$out" + # --------------------------------------------------------------------------------------- if [ "$fails" -eq 0 ]; then echo "ALL PASS" diff --git a/deploy/demo-up.sh b/deploy/demo-up.sh index 921ab19..fb15b5b 100644 --- a/deploy/demo-up.sh +++ b/deploy/demo-up.sh @@ -13,7 +13,8 @@ # 1. Prebuilt tars at deploy/images/felis-limbo.tar + felis-lobby.tar (imported as-is). # 2. Otherwise built on this host with docker, resolving the LOOHP/Limbo CI jar and # the latest stable Paper jar automatically. Override any of: -# LIMBO_JAR_URL LIMBO_SCHEM_URL LIMBO_VERSION PAPER_JAR_URL PAPER_MC_VERSION +# LIMBO_JAR_URL LIMBO_SCHEM_URL LIMBO_VERSION PAPER_JAR_URL PAPER_JAR_SHA256 +# PAPER_MC_VERSION # # Toggles: SKIP_BOOTSTRAP=1 (base already up), SKIP_SETUP=1 (stop before the TUI). set -Eeuo pipefail @@ -73,9 +74,24 @@ else : "${PAPER_MC_VERSION:=1.21.8}" : "${PAPER_JAR_URL:=$(curl -fsSL --max-time 30 "https://fill.papermc.io/v3/projects/paper/versions/${PAPER_MC_VERSION}/builds/latest" | grep -oE 'https://fill-data\.papermc\.io/[^"]+\.jar' | head -1)}" [ -n "$PAPER_JAR_URL" ] || die "could not resolve the Paper jar; set PAPER_JAR_URL" + # Both Dockerfiles require the jar's digest. The fill-data URL is content-addressed + # (the objects/ path segment IS the sha256), so it is derived rather than asked for; + # a mirror override carries no such segment and must bring its own digest. + if [ -z "${PAPER_JAR_SHA256:-}" ]; then + sha="${PAPER_JAR_URL#*/objects/}" + sha="${sha%%/*}" + case "$sha" in + *[!0-9a-f]*|"") sha="" ;; + esac + if [ "${#sha}" -ne 64 ]; then + die "cannot derive the Paper jar sha256 from PAPER_JAR_URL (not a content-addressed fill-data URL); set PAPER_JAR_SHA256" + fi + PAPER_JAR_SHA256="$sha" + fi log "building $LOBBY_IMAGE (Paper $PAPER_MC_VERSION)" docker build -f "$SRC_DIR/deploy/lobby/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ -t "$LOBBY_IMAGE" "$SRC_DIR" docker save "$LOBBY_IMAGE" | "$K3S" ctr images import - @@ -84,6 +100,7 @@ else log "building $PAPER_IMAGE (plain Paper $PAPER_MC_VERSION, forwarding via the operator initContainer)" docker build -f "$SRC_DIR/deploy/paper/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ + --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ -t "$PAPER_IMAGE" "$SRC_DIR" docker save "$PAPER_IMAGE" | "$K3S" ctr images import - fi diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 2216883..466db28 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -9,6 +9,7 @@ # 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 PAPER_JAR_SHA256= \ # --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 . @@ -42,6 +43,10 @@ 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 +# Required alongside the URL: Fill's URLs are content-addressed, but nothing enforces +# that shape at build time. Checking the digest after the download turns a truncated or +# tampered fetch into a failed build instead of a lobby booted on the wrong bytes. +ARG PAPER_JAR_SHA256 # 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 @@ -55,12 +60,16 @@ RUN set -eu; \ if [ -z "${PAPER_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg PAPER_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${PAPER_JAR_SHA256:-}" ]; then \ + echo "ERROR: --build-arg PAPER_JAR_SHA256= 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; \ mkdir -p /paper/plugins; \ curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ + echo "$PAPER_JAR_SHA256 /paper/paper.jar" | sha256sum -c; \ 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 diff --git a/deploy/lobby/README.md b/deploy/lobby/README.md index 6ef41fb..4c4f6c7 100644 --- a/deploy/lobby/README.md +++ b/deploy/lobby/README.md @@ -29,6 +29,7 @@ this at every layer: ``` docker build -f deploy/lobby/Dockerfile \ --build-arg PAPER_JAR_URL=https:///paper-1.21.x-.jar \ + --build-arg PAPER_JAR_SHA256= \ -t felis-lobby:demo . docker save felis-lobby:demo | sudo k3s ctr images import - # felis.toml → [velocity] lobby_image = "felis-lobby:demo" diff --git a/deploy/paper/Dockerfile b/deploy/paper/Dockerfile index 16be69c..e70f80a 100644 --- a/deploy/paper/Dockerfile +++ b/deploy/paper/Dockerfile @@ -18,6 +18,7 @@ # API — the SAME url the lobby build resolves, so this reuses it and adds no new dependency): # docker build -f deploy/paper/Dockerfile \ # --build-arg PAPER_JAR_URL=https://fill-data.papermc.io/v1/objects//paper--.jar \ +# --build-arg PAPER_JAR_SHA256= \ # -t felis-paper:demo . # docker save felis-paper:demo | sudo k3s ctr images import - # # felis.toml → recommended via 0019_recommended_paper.sql (no [velocity] key points here) @@ -30,13 +31,21 @@ # to boot on anything older. FROM eclipse-temurin:25-jre ARG PAPER_JAR_URL +# Required alongside the URL: Fill's URLs are content-addressed, but nothing enforces +# that shape at build time. Checking the digest after the download turns a truncated or +# tampered fetch into a failed build instead of a server booted on the wrong bytes. +ARG PAPER_JAR_SHA256 RUN set -eu; \ if [ -z "${PAPER_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg PAPER_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${PAPER_JAR_SHA256:-}" ]; then \ + echo "ERROR: --build-arg PAPER_JAR_SHA256= is required" >&2; exit 1; \ + fi; \ apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ mkdir -p /paper; \ curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \ + echo "$PAPER_JAR_SHA256 /paper/paper.jar" | sha256sum -c; \ apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY deploy/paper/entrypoint.sh /usr/local/bin/felis-entrypoint.sh