diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03ab875..9f38033 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,23 @@ -# The workflows pin every action to a commit SHA. This keeps those pins moving: Dependabot -# reads the "# vX.Y.Z" comment next to each SHA and opens a PR that bumps both together. +# The workflows pin every action to a commit SHA, and every Dockerfile pins its base images +# by digest. This keeps those pins moving: Dependabot reads the "# vX.Y.Z" comment next to +# each action SHA, and the tag in front of each image digest, and opens a PR that bumps both +# together. version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly + - package-ecosystem: docker + directories: + - / + - /deploy/limbo + - /deploy/lobby + - /deploy/paper + schedule: + interval: weekly + # A new major is a runtime change (Paper 26.x needs Java 25, Limbo's jar is Java 21 + # bytecode), so only digests and minors are proposed; majors move by hand. + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/Dockerfile b/Dockerfile index fb3eb3f..30d8f96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,14 +21,18 @@ # minutes. The FINAL stage is deliberately NOT pinned — it must stay on the target platform # or the published arm64 image would carry amd64 layers. It contains only COPY, which # BuildKit performs itself, so it needs no QEMU either; adding a RUN there would. -FROM --platform=$BUILDPLATFORM node:22-bookworm AS panel +# +# Every base image here and in deploy/{limbo,lobby,paper} is pinned by digest, so a rebuild +# of one release uses the same bytes; .github/dependabot.yml proposes the bumps (tag and +# digest together). +FROM --platform=$BUILDPLATFORM node:22-bookworm@sha256:363e1587494626837fa7f9a23bdb453d13b0ff3c67c705c2805cfc69c2d2fad7 AS panel WORKDIR /panel COPY panel/package*.json ./ RUN npm ci COPY panel/ ./ RUN npm run build -FROM --platform=$BUILDPLATFORM golang:1.26 AS build +FROM --platform=$BUILDPLATFORM golang:1.26@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9 AS build WORKDIR /src ARG TARGETOS=linux ARG TARGETARCH @@ -55,7 +59,7 @@ ARG FELIS_VERSION=dev RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \ go build -trimpath -ldflags="-s -w -X main.version=${FELIS_VERSION}" -o /out/felis ./cmd/felis -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab ENV PATH=/usr/local/bin:/usr/bin:/bin COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis # distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins diff --git a/bootstrap_asset.go b/bootstrap_asset.go index ff36377..2372844 100644 --- a/bootstrap_asset.go +++ b/bootstrap_asset.go @@ -24,6 +24,7 @@ var bootstrapAssets embed.FS // otherwise be baked into every felis binary. Keep them explicit — add a source // directory here, never a parent. // +//go:embed deploy/game-stack.lock //go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh //go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh //go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go index e7cec53..267694c 100644 --- a/bootstrap_asset_test.go +++ b/bootstrap_asset_test.go @@ -2,6 +2,7 @@ package felis import ( "io/fs" + "os" "regexp" "strings" "testing" @@ -205,6 +206,113 @@ func requireEmbedded(t *testing.T, path string) { } } +// The lock file is the install's only source of upstream builds, and bootstrap.sh reads it +// with a strict KEY=value parser that dies on anything unexpected, so a malformed lock is a +// failed install on every host. Check the shipped copy the same way here. +func TestGameStackLockIsComplete(t *testing.T) { + lock := map[string]string{} + for line := range strings.SplitSeq(readGameStackFile(t, "deploy/game-stack.lock"), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + t.Fatalf("not a KEY=value line: %q", line) + } + lock[k] = v + } + m := regexp.MustCompile(`GAME_STACK_LOCK_KEYS="([^"]*)"`).FindStringSubmatch(BootstrapScript()) + if m == nil { + t.Fatal("bootstrap.sh no longer declares GAME_STACK_LOCK_KEYS") + } + keys := strings.Fields(m[1]) + sha := regexp.MustCompile(`^[0-9a-f]{64}$`) + for _, k := range keys { + v, ok := lock[k] + if !ok || v == "" { + t.Errorf("game-stack.lock does not set %s", k) + continue + } + if strings.HasSuffix(k, "_SHA256") && !sha.MatchString(v) { + t.Errorf("%s=%q is not a lowercase sha256", k, v) + } + // A moving URL pins nothing: the digest check would start failing the day + // upstream publishes the next build. + if strings.HasSuffix(k, "_URL") && strings.Contains(v, "lastSuccessfulBuild") { + t.Errorf("%s names a moving build: %s", k, v) + } + } + for k := range lock { + if !strings.Contains(" "+m[1]+" ", " "+k+" ") { + t.Errorf("game-stack.lock sets %s, which bootstrap.sh refuses as an unknown key", k) + } + } + // Fill's URLs are content-addressed; a lock whose digest disagrees with its own URL + // was edited by hand and half-way. + for _, name := range []string{"PAPER", "VELOCITY"} { + if !strings.Contains(lock[name+"_JAR_URL"], "/objects/"+lock[name+"_JAR_SHA256"]+"/") { + t.Errorf("%s_JAR_SHA256 is not the digest in %s_JAR_URL", name, name) + } + } + if !strings.Contains(lock["LIMBO_JAR_URL"], "-"+lock["MC_VERSION"]+".jar") { + t.Errorf("LIMBO_JAR_URL %s is not a Minecraft %s build", lock["LIMBO_JAR_URL"], lock["MC_VERSION"]) + } + if !strings.Contains(lock["PAPER_JAR_URL"], "/paper-"+lock["MC_VERSION"]+"-") { + t.Errorf("PAPER_JAR_URL %s is not a Minecraft %s build; the lobby would not speak the login gate's protocol", lock["PAPER_JAR_URL"], lock["MC_VERSION"]) + } +} + +// Each downloaded jar's digest is a build-arg bootstrap.sh passes and the Dockerfile must +// both require and spend on the file it downloaded; docker only warns about an unknown +// --build-arg, so a renamed arg would ship an unchecked jar. +func TestGameStackDigestsReachTheImageBuilds(t *testing.T) { + script := BootstrapScript() + for _, c := range []struct{ dockerfile, arg, path string }{ + {"deploy/limbo/Dockerfile", "LIMBO_JAR_SHA256", "/limbo/Limbo.jar"}, + {"deploy/limbo/Dockerfile", "LIMBO_SCHEM_SHA256", "/limbo/spawn.schem"}, + {"deploy/lobby/Dockerfile", "LUCKPERMS_JAR_SHA256", "/paper/plugins/LuckPerms.jar"}, + } { + if !strings.Contains(script, "--build-arg "+c.arg+"=\"$"+c.arg+"\"") { + t.Errorf("bootstrap.sh never passes --build-arg %s", c.arg) + } + dockerfile := readGameStackFile(t, c.dockerfile) + if !strings.Contains(dockerfile, "ARG "+c.arg) { + t.Errorf("%s declares no ARG %s", c.dockerfile, c.arg) + } + if !strings.Contains(dockerfile, `echo "$`+c.arg+` `+c.path+`" | sha256sum -c`) { + t.Errorf("%s never verifies %s against %s", c.dockerfile, c.path, c.arg) + } + } +} + +// A base image named by tag alone is whatever the tag points at on build day. +func TestDockerfileBaseImagesArePinnedByDigest(t *testing.T) { + root, err := os.ReadFile("Dockerfile") + if err != nil { + t.Fatal(err) + } + files := map[string]string{"Dockerfile": string(root)} + for _, name := range []string{"deploy/limbo/Dockerfile", "deploy/lobby/Dockerfile", "deploy/paper/Dockerfile"} { + files[name] = readGameStackFile(t, name) + } + pinned := regexp.MustCompile(`^FROM (--platform=\S+ )?\S+:\S+@sha256:[0-9a-f]{64}( AS \S+)?$`) + for name, body := range files { + n := 0 + for line := range strings.SplitSeq(body, "\n") { + if !strings.HasPrefix(line, "FROM ") { + continue + } + n++ + if !pinned.MatchString(line) { + t.Errorf("%s: %q is not pinned by digest", name, line) + } + } + if n == 0 { + t.Errorf("%s has no FROM line", name) + } + } +} + func readGameStackFile(t *testing.T, name string) string { t.Helper() b, err := gameStackAssets.ReadFile(name) diff --git a/cmd/felis/update.go b/cmd/felis/update.go index e1341e4..808837e 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -72,7 +72,7 @@ var updateTargets = []updateTarget{ { selector: "velocity", component: "velocity", - note: "re-runs install_velocity: newest BUILD of the pinned minor (FELIS_VELOCITY_VERSION), atomic jar install, then restarts felis-velocity", + note: "re-runs install_velocity: the build the release pins in deploy/game-stack.lock (FELIS_VELOCITY_VERSION= takes that minor's newest build instead), sha256-checked, atomic jar install, then restarts felis-velocity only if the jar or its config changed", command: installerRerun, }, { diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 68758f0..2cac826 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -46,6 +46,14 @@ # proxy instead of the stock download (default: unset, stock). # FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar # above is set; the install refuses on a mismatch. +# FELIS_GAME_STACK pinned|latest — which Limbo, Paper, LuckPerms and Velocity builds to +# install (default: pinned, the builds deploy/game-stack.lock names, +# each checked against its sha256). latest resolves upstream's newest +# builds on every run; the Minecraft version then follows Limbo's CI. +# FELIS_JRE_VERSION Temurin feature version for the proxy (default: 25, whose build and +# digests are pinned; a rerun moves an installer-managed JRE to the +# pinned build). Another feature version is checked against the +# digest Adoptium's API publishes for it. # FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4) # FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture. # REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned. @@ -239,12 +247,17 @@ FELIS_LOBBY_IMAGE="${FELIS_LOBBY_IMAGE:-${REGISTRY_URL}/felis/lobby:demo}" # server — forwarding is applied by the operator's init-forwarding initContainer, so it # needs no secret. Seeded recommended in 0019_recommended_paper.sql. FELIS_PAPER_IMAGE="${FELIS_PAPER_IMAGE:-${REGISTRY_URL}/felis/paper:demo}" -# The Velocity MINOR is pinned, not discovered. PaperMC's Fill v3 groups velocity -# builds by version group, and "newest across all groups" today means 4.0.0-SNAPSHOT — -# an UNRELEASED proxy (the 4.0.0 group has zero published builds) that needs a Java 25 -# runtime. Crossing a major is a deliberate code change, so we track the newest BUILD of -# a pinned minor and let a human move the pin. -FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-3.5.1}" +# The Velocity build comes from deploy/game-stack.lock (VELOCITY_VERSION and its jar digest). +# Setting FELIS_VELOCITY_VERSION to another version, or FELIS_GAME_STACK=latest, installs +# the newest BUILD of that minor instead. The minor itself is never discovered: PaperMC's +# Fill v3 groups velocity builds by version group, and "newest across all groups" can mean +# an unreleased 4.x SNAPSHOT that needs another runtime. Crossing a major is a deliberate +# code change. +FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-}" +VELOCITY_LATEST_MINOR="3.5.1" +# pinned installs the builds deploy/game-stack.lock names (resolve_game_jars); latest asks +# upstream for its newest ones, which is how that lock file gets refreshed. +FELIS_GAME_STACK="${FELIS_GAME_STACK:-pinned}" # Path to a Felis-Legacy Velocity fork build, installed as the proxy in place of the # stock download. Unset — the default — changes nothing. # @@ -275,6 +288,14 @@ FELIS_VELOCITY_FORK_JAR_SHA256="${FELIS_VELOCITY_FORK_JAR_SHA256:-}" # a lottery across four package managers — a tarball is one code path everywhere (same # reasoning as install_go_toolchain). FELIS_JRE_VERSION="${FELIS_JRE_VERSION:-25}" +# The JRE the proxy runs on is unpacked as root and carries every player session, so the +# default feature version is pinned to one build and the sha256 Adoptium publishes for each +# architecture. Move the three together (the Adoptium API lists them: +# /v3/assets/latest/25/hotspot?image_type=jre&os=linux). +JRE_PINNED_FEATURE="25" +JRE_PINNED_RELEASE="25.0.4.1+1" +JRE_PINNED_SHA256_X64="1731a34baadec5479258ea0202e4d5d865d2efeee60cb0c7d7eb056fe96ca219" +JRE_PINNED_SHA256_AARCH64="34828cbb93ed31c281c84ecb31ddab655d11a802f263c1fc019d42e9e0230fed" # The port the proxy listens on: the ONLY Minecraft port players ever touch. Backends # are ClusterIP-only, verify the modern-forwarding HMAC, and use NetworkPolicy to limit # non-node ingress to the declared proxy CIDRs. @@ -671,6 +692,10 @@ validate_settings() { validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN" validate_cidr FELIS_NANO_PROXY_CIDR "$FELIS_NANO_PROXY_CIDR" validate_offsite_settings + case "$FELIS_GAME_STACK" in + pinned|latest) ;; + *) die "FELIS_GAME_STACK must be pinned or latest (got '${FELIS_GAME_STACK}')" ;; + esac } # validate_offsite_settings checks the FELIS_OFFSITE_* inputs before anything is @@ -1198,7 +1223,7 @@ github_api() { # can never disagree about what "latest" means. github_latest_tag() { local json tag - # Fetch first, filter second — the SIGPIPE reason documented on resolve_game_jars. + # Fetch first, filter second — the SIGPIPE reason documented on resolve_latest_game_jars. json="$(github_api "repos/$(repo_slug)/releases/latest")" || return 1 tag="$(printf '%s' "$json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' || true)" tag="${tag%%$'\n'*}" @@ -1236,7 +1261,7 @@ felis_asset_arch() { # reachable this way — fetch releases/assets/ with the JSON Accept if that is ever needed. github_asset_id() { local tag="$1" name="$2" json id - # Fetch first, filter second — the SIGPIPE reason documented on resolve_game_jars. + # Fetch first, filter second — the SIGPIPE reason documented on resolve_latest_game_jars. json="$(github_api "repos/$(repo_slug)/releases/tags/${tag}")" || return 1 id="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' \ | grep "\"name\":[[:space:]]*\"${name}\"" \ @@ -1685,11 +1710,6 @@ game_stack_source() { ok "game-stack sources unpacked to ${GAME_STACK_DIR}" } -# resolve_game_jars pins Limbo and Paper to the SAME Minecraft version. LOOHP/Limbo -# speaks exactly one protocol per build, so the login gate dictates the version and Paper -# follows — a client that can pass the gate must also be able to reach the lobby. -# MC_VERSION is read off Limbo's CI artifact name (Limbo--.jar), which -# is the only place the pairing is published. # meta_get prints a small metadata document. curl's --retry covers transient HTTP # statuses and timeouts; a TLS handshake cut mid-way (exit 35, seen against Fill over # a flaky IPv6 path) is outside its retry set, so the outer loop retries every failure @@ -1710,8 +1730,71 @@ meta_get() { return 1 } +# resolve_game_jars sets the artifacts the three game images and the proxy are built from: +# the builds deploy/game-stack.lock names (FELIS_GAME_STACK=pinned, the default), or +# upstream's newest ones (latest). Pinned is what makes an install reproducible: every host +# installing one release gets the same login gate, lobby and proxy, each download is +# checked against the lock's sha256, and a rerun's docker builds hit their cache, so +# restart_existing_system_servers leaves the login and lobby pods running. resolve_game_jars() { - local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta file base rest paper + if [ "$FELIS_GAME_STACK" = "latest" ]; then + resolve_latest_game_jars + return 0 + fi + load_game_stack_lock "${GAME_STACK_DIR}/deploy/game-stack.lock" + ok "Limbo ${LIMBO_VERSION} + Paper on Minecraft ${MC_VERSION}, LuckPerms and Velocity ${VELOCITY_VERSION}: the builds game-stack.lock pins" +} + +GAME_STACK_LOCK_KEYS="MC_VERSION LIMBO_VERSION LIMBO_JAR_URL LIMBO_JAR_SHA256 LIMBO_SCHEM_URL LIMBO_SCHEM_SHA256 PAPER_JAR_URL PAPER_JAR_SHA256 LUCKPERMS_JAR_URL LUCKPERMS_JAR_SHA256 VELOCITY_VERSION VELOCITY_JAR_URL VELOCITY_JAR_SHA256" + +# load_game_stack_lock reads the lock's KEY=value lines into the globals of the same names. +# It never sources the file: only the keys above are accepted, every one has to be set, the +# values are limited to URL and version characters (they reach docker build-args), and each +# *_SHA256 has to be a sha256. +load_game_stack_lock() { + local file="$1" line key value + [ -f "$file" ] || die "no game-stack lock at ${file}; FELIS_GAME_STACK=latest resolves upstream's newest builds instead" + for key in $GAME_STACK_LOCK_KEYS; do printf -v "$key" '%s' ""; done + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|'#'*) continue ;; esac + key="${line%%=*}" + value="${line#*=}" + [ "$key" != "$line" ] || die "${file}: not a KEY=value line: ${line}" + case " ${GAME_STACK_LOCK_KEYS} " in + *" ${key} "*) ;; + *) die "${file}: unknown key ${key}" ;; + esac + case "$value" in + ''|*[!A-Za-z0-9._:/+%-]*) die "${file}: ${key} has an unexpected value: ${value}" ;; + esac + printf -v "$key" '%s' "$value" + done < "$file" + for key in $GAME_STACK_LOCK_KEYS; do + [ -n "${!key}" ] || die "${file} does not set ${key}" + case "$key" in + *_SHA256) [[ "${!key}" =~ ^[0-9a-f]{64}$ ]] || die "${file}: ${key} is not a lowercase sha256" ;; + esac + done +} + +# url_sha256 prints the sha256 of what $1 serves. +url_sha256() { + local sum + sum="$(curl -fsSL --retry 5 --retry-delay 2 -A "felis-bootstrap (+https://github.com/FelisMC/Felis)" "$1" | sha256sum)" || return 1 + printf '%s\n' "${sum%% *}" +} + +# resolve_latest_game_jars pins Limbo and Paper to the SAME Minecraft version. LOOHP/Limbo +# speaks exactly one protocol per build, so the login gate dictates the version and Paper +# follows — a client that can pass the gate must also be able to reach the lobby. +# MC_VERSION is read off Limbo's CI artifact name (Limbo--.jar), which +# is the only place the pairing is published. +# +# Limbo's CI and LuckPerms publish no digest, so latest hashes their downloads as it finds +# them: the image builds still check that they receive those bytes, but nothing vouches for +# the bytes themselves. That is what the lock file adds. +resolve_latest_game_jars() { + local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta build 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. @@ -1720,6 +1803,13 @@ resolve_game_jars() { file="$(printf '%s' "$meta" | grep -o 'Limbo-[0-9A-Za-z._-]*\.jar' || true)" file="${file%%$'\n'*}" [ -n "$file" ] || die "no Limbo jar in the LOOHP/Limbo CI artifact list" + # The numbered build, so the jar hashed below is the jar the image build downloads even + # if CI finishes another build in between. + build="$(printf '%s' "$meta" | grep -o '"number":[0-9]*' || true)" + build="${build%%$'\n'*}" + build="${build#*:}" + [ -n "$build" ] || die "no build number in the LOOHP/Limbo CI metadata" + ci="https://ci.loohpjames.com/job/Limbo/${build}" base="${file%.jar}" # Limbo-2026.0.2-ALPHA-26.2 MC_VERSION="${base##*-}" # 26.2 @@ -1742,7 +1832,16 @@ resolve_game_jars() { 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" + + log "hashing the Limbo and LuckPerms downloads (their upstreams publish no digest)" + LIMBO_JAR_SHA256="$(url_sha256 "$LIMBO_JAR_URL")" || die "could not download ${LIMBO_JAR_URL}" + LIMBO_SCHEM_SHA256="$(url_sha256 "$LIMBO_SCHEM_URL")" || die "could not download ${LIMBO_SCHEM_URL}" + LUCKPERMS_JAR_SHA256="$(url_sha256 "$LUCKPERMS_JAR_URL")" || die "could not download ${LUCKPERMS_JAR_URL}" + VELOCITY_VERSION="$VELOCITY_LATEST_MINOR" + VELOCITY_JAR_URL="" + VELOCITY_JAR_SHA256="" + ok "Limbo ${LIMBO_VERSION} (CI build ${build}) + Paper, both on Minecraft ${MC_VERSION}; LuckPerms resolved" + warn "FELIS_GAME_STACK=latest: these are upstream's builds as of now, not the ones this release pins" } # luckperms_latest_jar prints the download URL of the current LuckPerms Bukkit build. @@ -1792,7 +1891,9 @@ build_game_stack() { log "building ${FELIS_LIMBO_IMAGE} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ + --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ + --build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \ --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ -t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR" @@ -1801,6 +1902,7 @@ build_game_stack() { --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ + --build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \ -t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR" # Plain Paper, same MC_VERSION and PAPER_JAR_URL (no new dependency). Forwarding is the @@ -1985,29 +2087,84 @@ pin_via_block_connections() { } install_jre() { - local arch url - if [ -x "${JRE_DIR}/bin/java" ]; then - ok "JRE already installed at ${JRE_DIR}" - return 0 - fi + local arch want url release json case "$(uname -m)" in - x86_64|amd64) arch="x64" ;; - aarch64|arm64) arch="aarch64" ;; - *) die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}" ;; + x86_64|amd64) arch="x64"; want="$JRE_PINNED_SHA256_X64" ;; + aarch64|arm64) arch="aarch64"; want="$JRE_PINNED_SHA256_AARCH64" ;; + *) + if [ -x "${JRE_DIR}/bin/java" ]; then + ok "JRE already installed at ${JRE_DIR}" + return 0 + fi + die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}" + ;; esac - url="https://api.adoptium.net/v3/binary/latest/${FELIS_JRE_VERSION}/ga/linux/${arch}/jre/hotspot/normal/eclipse" - log "installing Temurin ${FELIS_JRE_VERSION} JRE (${arch}) to ${JRE_DIR}" - local tmp + if [ "$FELIS_JRE_VERSION" = "$JRE_PINNED_FEATURE" ]; then + release="$JRE_PINNED_RELEASE" + url="https://github.com/adoptium/temurin${JRE_PINNED_FEATURE}-binaries/releases/download/jdk-${release/+/%2B}/OpenJDK${JRE_PINNED_FEATURE}U-jre_${arch}_linux_hotspot_${release/+/_}.tar.gz" + else + # Another feature version is installed once and then left alone; its digest is the + # one Adoptium's API publishes next to the link. + if [ -x "${JRE_DIR}/bin/java" ]; then + ok "JRE already installed at ${JRE_DIR}" + return 0 + fi + json="$(meta_get "https://api.adoptium.net/v3/assets/latest/${FELIS_JRE_VERSION}/hotspot?architecture=${arch}&image_type=jre&os=linux&vendor=eclipse")" \ + || die "could not ask the Adoptium API for a Temurin ${FELIS_JRE_VERSION} JRE" + url="$(printf '%s' "$json" | grep -o '"link": *"[^"]*\.tar\.gz"' || true)" + url="${url%%$'\n'*}" + url="${url%\"}" + url="${url##*\"}" + want="$(printf '%s' "$json" | grep -o '"checksum": *"[0-9a-f]\{64\}"' || true)" + want="${want%%$'\n'*}" + want="${want%\"}" + want="${want##*\"}" + release="$(printf '%s' "$json" | grep -o '"release_name": *"jdk-[^"]*"' || true)" + release="${release%%$'\n'*}" + release="${release%\"}" + release="${release##*\"jdk-}" + [ -n "$url" ] && [ -n "$want" ] && [ -n "$release" ] \ + || die "the Adoptium API lists no Temurin ${FELIS_JRE_VERSION} JRE for linux/${arch}" + fi + + # A rerun moves the installer's own JRE to the pinned build, which is how a JRE security + # release reaches the proxy: bump the pin, rerun, and install_velocity_service restarts + # the proxy because the JRE's release file changed. A JRE someone else put here (another + # vendor, or pre-staged for an architecture Temurin does not build) is left alone. + if [ -x "${JRE_DIR}/bin/java" ]; then + if grep -qxF "IMPLEMENTOR_VERSION=\"Temurin-${release}\"" "${JRE_DIR}/release" 2>/dev/null; then + ok "Temurin ${release} JRE already installed at ${JRE_DIR}" + return 0 + fi + if ! grep -qxF 'IMPLEMENTOR="Eclipse Adoptium"' "${JRE_DIR}/release" 2>/dev/null; then + ok "JRE at ${JRE_DIR} is not a Temurin build this installer put there; left as is" + return 0 + fi + log "moving the proxy's JRE to Temurin ${release}" + fi + + log "installing Temurin ${release} JRE (${arch}) to ${JRE_DIR}" + local tmp have tmp="$(mktemp -d)" remember_temp "$tmp" - curl -fsSL "$url" -o "${tmp}/jre.tar.gz" || die "failed to download the Temurin JRE: ${url}" - mkdir -p "$JRE_DIR" + curl -fsSL --retry 5 --retry-delay 2 "$url" -o "${tmp}/jre.tar.gz" \ + || die "failed to download the Temurin JRE: ${url}" + have="$(sha256sum <"${tmp}/jre.tar.gz" | cut -d' ' -f1)" + [ "$have" = "$want" ] \ + || die "Temurin ${release} JRE (${arch}) hashes to ${have}, expected ${want}; refusing to install it" + # Unpacked beside the live one and swapped in with two renames, so a failed unpack leaves + # the proxy's runtime untouched. The running proxy keeps the files it has open. + rm -rf "${JRE_DIR}.new" "${JRE_DIR}.old" + mkdir -p "${JRE_DIR}.new" # The tarball has a single versioned top-level directory (jdk-25+36-jre/); strip it so # the path in the systemd unit never carries a build number. - tar -C "$JRE_DIR" --strip-components=1 -xzf "${tmp}/jre.tar.gz" || die "failed to unpack the JRE" - [ -x "${JRE_DIR}/bin/java" ] || die "unpacked JRE has no bin/java" - ok "JRE at ${JRE_DIR}/bin/java" + tar -C "${JRE_DIR}.new" --strip-components=1 -xzf "${tmp}/jre.tar.gz" || die "failed to unpack the JRE" + [ -x "${JRE_DIR}.new/bin/java" ] || die "unpacked JRE has no bin/java" + [ ! -e "$JRE_DIR" ] || mv "$JRE_DIR" "${JRE_DIR}.old" + mv "${JRE_DIR}.new" "$JRE_DIR" + rm -rf "${JRE_DIR}.old" + ok "JRE at ${JRE_DIR}/bin/java (Temurin ${release})" } install_velocity() { @@ -2039,22 +2196,18 @@ install_velocity() { log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR} (sha256 ${have})" atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root else - log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build" - 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 + local version="${FELIS_VELOCITY_VERSION:-${VELOCITY_VERSION:-$VELOCITY_LATEST_MINOR}}" + if [ "$FELIS_GAME_STACK" = "pinned" ] && [ "$version" = "${VELOCITY_VERSION:-}" ]; then + url="$VELOCITY_JAR_URL" + want="$VELOCITY_JAR_SHA256" + else + log "resolving the newest Velocity ${version} build" + resolved="$(papermc_latest_jar velocity "$version")" \ + || die "no Velocity build for ${version} (override with FELIS_VELOCITY_VERSION)" + url="${resolved% *}" + want="${resolved##* }" + fi + stage_velocity_jar "$url" "$want" "$version" fi install_via_plugins @@ -2063,6 +2216,29 @@ install_velocity() { configure_velocity_firewall } +# stage_velocity_jar installs the stock proxy from $1 unless velocity.jar already hashes to +# $2, so a rerun of the same build neither downloads nor touches the file the proxy runs. +stage_velocity_jar() { + local url="$1" want="$2" version="$3" have="" tmp + [ -f "${VELOCITY_DIR}/velocity.jar" ] && have="$(sha256sum <"${VELOCITY_DIR}/velocity.jar" | cut -d' ' -f1)" + if [ "$have" = "$want" ]; then + ok "Velocity ${version} already staged" + return 0 + fi + log "downloading Velocity ${version}" + tmp="$(mktemp "${VELOCITY_DIR}/.velocity.jar.XXXXXX")" + remember_temp "$tmp" + curl -fsSL --retry 5 --retry-delay 2 "$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 the lock file (or Fill's content-addressed URL) names its + # digest — 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 ${version} checksum mismatch: got ${have}, expected ${want}" + atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar" 0644 root root +} + # felis_internal_ip echoes the felis-api-internal Service ClusterIP. Cluster DNS does not # resolve from the host, but a Service ClusterIP DOES route from the node (kube-proxy programs # the host netns) — the same trick the on-node break-glass console uses. The internal face is diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 9ef092f..5c6bbf2 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -120,32 +120,27 @@ out="$(bash -c ' ')" expect "meta_get gives up after three attempts" "GAVE UP" "$out" -# --- the resolved-Velocity digest gate -------------------------------------------------- -# The download must hash to what the content-addressed URL promised, BEFORE +# --- the Velocity digest gate ----------------------------------------------------------- +# The download must hash to what the lock (or 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; } +vblock="$(awk '/^stage_velocity_jar\(\) \{/,/^}/' "$BS")" +[ -n "$vblock" ] || { echo "FAIL: no stage_velocity_jar found in $BS"; 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 ' +run_velocity_install() { # expected-digest + WANT="$1" VELOCITY_DIR="$vdir" bash -c ' die() { printf "DIE: %s\n" "$*"; exit 1; } log() { printf "LOG: %s\n" "$*"; } + ok() { printf "OK: %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" + atomic_install_file() { printf "INSTALL: %s\n" "$2"; cp "$1" "$2"; } + '"$vblock"' + stage_velocity_jar "https://fill-data.papermc.io/v1/objects/${WANT}/velocity-3.5.1-615.jar" "$WANT" 3.5.1' } out="$(run_velocity_install deadbeef)" @@ -158,6 +153,111 @@ esac out="$(run_velocity_install "$vwant")" expect "the matching download installs" "INSTALL: ${vdir}/velocity.jar" "$out" +out="$(run_velocity_install "$vwant")" +case "$out" in + *LOG:*|*INSTALL:*) echo "FAIL a rerun of the staged build downloaded it again"; fails=$((fails + 1)) ;; + *"Velocity 3.5.1 already staged"*) echo "PASS a rerun of the staged build leaves velocity.jar alone" ;; + *) echo "FAIL stage_velocity_jar died on a staged build: $out"; fails=$((fails + 1)) ;; +esac + +ivblock="$(awk '/^install_velocity\(\) \{/,/^}/' "$BS")" +run_velocity_choice() { # FELIS_GAME_STACK FELIS_VELOCITY_VERSION lock-version + FELIS_GAME_STACK="$1" FELIS_VELOCITY_VERSION="$2" VELOCITY_VERSION="$3" VELOCITY_LATEST_MINOR=3.5.1 \ + VELOCITY_JAR_URL=https://fill-data.papermc.io/v1/objects/aaa/velocity-3.5.1-615.jar VELOCITY_JAR_SHA256=aaa \ + FELIS_VELOCITY_FORK_JAR= bash -c ' + set -Eeuo pipefail + log() { :; } + die() { printf "DIE: %s\n" "$*"; exit 1; } + install_jre() { :; } + prepare_velocity_layout() { :; } + install_via_plugins() { :; } + write_velocity_config() { :; } + install_velocity_service() { :; } + configure_velocity_firewall() { :; } + papermc_latest_jar() { printf "https://fill-data.papermc.io/v1/objects/bbb/velocity-%s-700.jar bbb\n" "$2"; } + stage_velocity_jar() { printf "STAGE %s %s %s\n" "$@"; } + '"$ivblock"' + install_velocity' +} +expect "a pinned install stages the lock's Velocity build" \ + "STAGE https://fill-data.papermc.io/v1/objects/aaa/velocity-3.5.1-615.jar aaa 3.5.1" "$(run_velocity_choice pinned '' 3.5.1)" +expect "another FELIS_VELOCITY_VERSION resolves that minor's newest build" \ + "STAGE https://fill-data.papermc.io/v1/objects/bbb/velocity-3.6.0-700.jar bbb 3.6.0" "$(run_velocity_choice pinned 3.6.0 3.5.1)" +expect "FELIS_GAME_STACK=latest resolves the newest build of the default minor" \ + "STAGE https://fill-data.papermc.io/v1/objects/bbb/velocity-3.5.1-700.jar bbb 3.5.1" "$(run_velocity_choice latest '' 3.5.1)" + +# --- the game-stack lock ---------------------------------------------------------------- +# bootstrap.sh reads deploy/game-stack.lock as data: known keys only, all of them present, +# values limited to URL and version characters, digests shaped like digests. + +lblock="$(awk '/^load_game_stack_lock\(\) \{/,/^}/' "$BS")" +[ -n "$lblock" ] || { echo "FAIL: no load_game_stack_lock found in $BS"; exit 1; } +lkeys="$(grep '^GAME_STACK_LOCK_KEYS=' "$BS")" +[ -n "$lkeys" ] || { echo "FAIL: no GAME_STACK_LOCK_KEYS in $BS"; exit 1; } +ldir="$(mktemp -d)" +run_lock() { # lock-file + LOCK="$1" bash -c ' + set -Eeuo pipefail + die() { printf "DIE: %s\n" "$*"; exit 1; } + '"$lkeys"' + '"$lblock"' + load_game_stack_lock "$LOCK" + printf "MC=%s LIMBO=%s VELOCITY=%s\n" "$MC_VERSION" "$LIMBO_JAR_URL" "$VELOCITY_JAR_SHA256"' +} +repo_lock="$(dirname "$BS")/game-stack.lock" +out="$(run_lock "$repo_lock")" +expect "the shipped lock loads" "MC=$(sed -n 's/^MC_VERSION=//p' "$repo_lock") LIMBO=https://ci.loohpjames.com/job/Limbo/" "$out" +{ cat "$repo_lock"; printf 'EVIL=$(touch /tmp/pwned)\n'; } > "$ldir/unknown" +expect "an unknown key is refused" "DIE: $ldir/unknown: unknown key EVIL" "$(run_lock "$ldir/unknown")" +sed 's|^LIMBO_VERSION=.*|LIMBO_VERSION=$(id)|' "$repo_lock" > "$ldir/subst" +expect "a value with shell syntax is refused" "DIE: $ldir/subst: LIMBO_VERSION has an unexpected value" "$(run_lock "$ldir/subst")" +grep -v '^LUCKPERMS_JAR_SHA256=' "$repo_lock" > "$ldir/missing" +expect "a missing key is refused" "DIE: $ldir/missing does not set LUCKPERMS_JAR_SHA256" "$(run_lock "$ldir/missing")" +sed 's|^PAPER_JAR_SHA256=.*|PAPER_JAR_SHA256=ABCDEF|' "$repo_lock" > "$ldir/badsha" +expect "a malformed digest is refused" "DIE: $ldir/badsha: PAPER_JAR_SHA256 is not a lowercase sha256" "$(run_lock "$ldir/badsha")" +expect "no lock file is refused with the way out" "FELIS_GAME_STACK=latest" "$(run_lock "$ldir/none")" +rm -rf "$ldir" + +# --- the Temurin JRE pin ---------------------------------------------------------------- +jblock="$(awk '/^install_jre\(\) \{/,/^}/' "$BS")" +[ -n "$jblock" ] || { echo "FAIL: no install_jre found in $BS"; exit 1; } +jdir="$(mktemp -d)" +jtar="$(mktemp -d)" +mkdir -p "$jtar/jdk-25.0.9+1-jre/bin" +printf '#!/bin/sh\n' > "$jtar/jdk-25.0.9+1-jre/bin/java" +chmod +x "$jtar/jdk-25.0.9+1-jre/bin/java" +printf 'IMPLEMENTOR="Eclipse Adoptium"\nIMPLEMENTOR_VERSION="Temurin-25.0.9+1"\n' > "$jtar/jdk-25.0.9+1-jre/release" +tar -C "$jtar" -czf "$jtar/jre.tar.gz" "jdk-25.0.9+1-jre" +jsha="$(sha256sum < "$jtar/jre.tar.gz" | cut -d' ' -f1)" +run_jre() { # machine pinned-sha + MACHINE="$1" SHA="$2" TARBALL="$jtar/jre.tar.gz" JRE_DIR="$jdir/jre" FELIS_JRE_VERSION=25 \ + JRE_PINNED_FEATURE=25 JRE_PINNED_RELEASE=25.0.9+1 JRE_PINNED_SHA256_X64="$2" JRE_PINNED_SHA256_AARCH64="$2" bash -c ' + set -Eeuo pipefail + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { printf "LOG: %s\n" "$*"; } + ok() { printf "OK: %s\n" "$*"; } + remember_temp() { :; } + uname() { printf "%s\n" "$MACHINE"; } + curl() { local prev=""; while [ "$#" -gt 1 ] && [ "$1" != "-o" ]; do prev="$1"; shift; done; printf "URL %s\n" "$prev" >&2; cp "$TARBALL" "$2"; } + '"$jblock"' + install_jre' 2>&1 +} +out="$(run_jre x86_64 deadbeef)" +expect "a JRE download with the wrong digest is refused" "hashes to ${jsha}, expected deadbeef" "$out" +[ -e "$jdir/jre" ] && { echo "FAIL a refused JRE was unpacked"; fails=$((fails + 1)); } || echo "PASS a refused JRE is not unpacked" +out="$(run_jre aarch64 "$jsha")" +expect "the pinned JRE is downloaded from its GitHub release" \ + "URL https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.9%2B1/OpenJDK25U-jre_aarch64_linux_hotspot_25.0.9_1.tar.gz" "$out" +expect "the pinned JRE installs" "OK: JRE at $jdir/jre/bin/java (Temurin 25.0.9+1)" "$out" +expect "a rerun of the pinned JRE is a no-op" "OK: Temurin 25.0.9+1 JRE already installed" "$(run_jre x86_64 "$jsha")" +printf 'IMPLEMENTOR="Eclipse Adoptium"\nIMPLEMENTOR_VERSION="Temurin-25.0.1+8"\n' > "$jdir/jre/release" +out="$(run_jre x86_64 "$jsha")" +expect "an older installer-managed JRE moves to the pin" "LOG: moving the proxy's JRE to Temurin 25.0.9+1" "$out" +expect "the moved JRE is the pinned build" 'IMPLEMENTOR_VERSION="Temurin-25.0.9+1"' "$(cat "$jdir/jre/release")" +printf 'IMPLEMENTOR="Azul Systems, Inc."\n' > "$jdir/jre/release" +expect "a JRE someone else installed is left alone" "is not a Temurin build this installer put there" "$(run_jre x86_64 "$jsha")" +expect "an unsupported architecture keeps a pre-staged JRE" "OK: JRE already installed at $jdir/jre" "$(run_jre riscv64 "$jsha")" +rm -rf "$jdir" "$jtar" # --- [[auth_source]] carry-forward ----------------------------------------------------- # write_felis_toml regenerates felis.toml wholesale on every run; this is what keeps the diff --git a/deploy/game-stack.lock b/deploy/game-stack.lock new file mode 100644 index 0000000..2fb97f0 --- /dev/null +++ b/deploy/game-stack.lock @@ -0,0 +1,23 @@ +# The upstream builds this release installs. deploy/bootstrap.sh reads this file (the +# default FELIS_GAME_STACK=pinned), downloads exactly these artifacts and refuses any whose +# sha256 differs, so every host installing one release gets the same login gate, lobby, +# plain-Paper image and proxy, and a rerun rebuilds nothing that did not change. +# +# MC_VERSION is the protocol the whole stack speaks: Limbo speaks exactly one, and Paper +# follows it so a client that passes the login gate can also reach the lobby. +# +# Refresh with deploy/update-game-stack-lock.sh, which resolves upstream's newest builds and +# hashes them. Plain KEY=value lines only; bootstrap reads it without evaluating it. +MC_VERSION=26.3 +LIMBO_VERSION=2026.0.3-ALPHA +LIMBO_JAR_URL=https://ci.loohpjames.com/job/Limbo/76/artifact/target/Limbo-2026.0.3-ALPHA-26.3.jar +LIMBO_JAR_SHA256=a2de91fcaa2255aed8a111b8786f370213423c7798eee778c00d016d83aa65d2 +LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo/76/artifact/spawn.schem +LIMBO_SCHEM_SHA256=70c85dae2db157971ef513e318820c5a5e10a96b813b70e21f8af2b632cbcbc7 +PAPER_JAR_URL=https://fill-data.papermc.io/v1/objects/49399919246cbf443efc8507447dc948eb7477c41be560b0e87e2a455aff824a/paper-26.3-40.jar +PAPER_JAR_SHA256=49399919246cbf443efc8507447dc948eb7477c41be560b0e87e2a455aff824a +LUCKPERMS_JAR_URL=https://download.luckperms.net/1672/bukkit/loader/LuckPerms-Bukkit-5.5.85.jar +LUCKPERMS_JAR_SHA256=dc637ce18f48d3b75a7ffd1784b85be16a627359090dfe5adf15dab6d145dc7d +VELOCITY_VERSION=3.5.1 +VELOCITY_JAR_URL=https://fill-data.papermc.io/v1/objects/b4e3164df5377346854dc6cb9e6a78022b1946ff69e89676313f5f6f1c6f0fb3/velocity-3.5.1-615.jar +VELOCITY_JAR_SHA256=b4e3164df5377346854dc6cb9e6a78022b1946ff69e89676313f5f6f1c6f0fb3 diff --git a/deploy/limbo/Dockerfile b/deploy/limbo/Dockerfile index 7b4374b..aecbf0d 100644 --- a/deploy/limbo/Dockerfile +++ b/deploy/limbo/Dockerfile @@ -10,10 +10,11 @@ # There is no bundled server.properties — Limbo writes a default on first run. # So the runtime is assembled from those two URLs (not a zip) via --build-arg: # +# . <(grep '^LIMBO_' deploy/game-stack.lock) # docker build -f deploy/limbo/Dockerfile \ -# --build-arg LIMBO_JAR_URL=https://ci.loohpjames.com/job/Limbo//artifact/target/Limbo-.jar \ -# --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo//artifact/spawn.schem \ -# --build-arg LIMBO_VERSION= \ +# --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ +# --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" --build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \ +# --build-arg LIMBO_VERSION="$LIMBO_VERSION" \ # -t felis-limbo:demo . # # Note LIMBO_VERSION (the maven API version the plugin compiles against, e.g. @@ -33,7 +34,7 @@ # JDK 17 fails to read them with "wrong version 65.0, should be 61.0". The image # also provides the `gradle` binary (this tree vendors no Gradle wrapper). # build.gradle still targets release 17 bytecode so the plugin loads on Java 17+. -FROM gradle:8.14-jdk21 AS plugin +FROM gradle:8.14-jdk21@sha256:5c4c0c4284de4a19951e82ac78f86dbcda2e136644bbfe159beba7ea3420cc80 AS plugin WORKDIR /src # Copy what the limbo module needs: its own tree plus the shared link core it # srcDir-includes (../shared/src/main/java → /src/plugins/shared/src/main/java), so @@ -50,21 +51,33 @@ RUN cd plugins/limbo \ # 21-jre: the Limbo jar is Java 21 bytecode (class-file major 65), so a Java 17 # JRE cannot run it (UnsupportedClassVersionError). A 21 JRE also runs the # plugin's release-17 bytecode fine. -FROM eclipse-temurin:21-jre +FROM eclipse-temurin:21-jre@sha256:49e21e16e3c86eb7816a44a67549910ed090fbeb40c29c525d58bf5e02e91b0f ARG LIMBO_JAR_URL +ARG LIMBO_JAR_SHA256 ARG LIMBO_SCHEM_URL +ARG LIMBO_SCHEM_SHA256 WORKDIR /limbo # Pull the two loose LOOHP/Limbo CI artifacts: the server jar (required, saved as -# Limbo.jar) and the default spawn schematic (optional). Fail loudly if the jar -# URL was not supplied. +# Limbo.jar) and the default spawn schematic (optional). Each is checked against the +# digest deploy/game-stack.lock names (bootstrap.sh passes it): the login gate is the +# first thing every player's connection reaches, and Limbo's CI publishes no digest of +# its own. RUN set -eu; \ if [ -z "${LIMBO_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg LIMBO_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${LIMBO_JAR_SHA256:-}" ]; then \ + echo "ERROR: --build-arg LIMBO_JAR_SHA256= is required" >&2; exit 1; \ + fi; \ + if [ -n "${LIMBO_SCHEM_URL:-}" ] && [ -z "${LIMBO_SCHEM_SHA256:-}" ]; then \ + echo "ERROR: --build-arg LIMBO_SCHEM_SHA256= is required with LIMBO_SCHEM_URL" >&2; exit 1; \ + fi; \ apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \ curl -fSL "$LIMBO_JAR_URL" -o /limbo/Limbo.jar; \ + echo "$LIMBO_JAR_SHA256 /limbo/Limbo.jar" | sha256sum -c; \ if [ -n "${LIMBO_SCHEM_URL:-}" ]; then \ curl -fSL "$LIMBO_SCHEM_URL" -o /limbo/spawn.schem; \ + echo "$LIMBO_SCHEM_SHA256 /limbo/spawn.schem" | sha256sum -c; \ fi; \ apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \ mkdir -p /limbo/plugins diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 2d75ca7..3ddc53b 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -5,13 +5,13 @@ # the POST-auth /menu hub: it is reached only when the login gate transfers an # authenticated player onward, and it must never be a fallback target. # -# Build (deploy/bootstrap.sh does this for you; the PAPER_JAR_URL comes from PaperMC's -# Fill v3 API — api.papermc.io v2 has returned HTTP 410 since 2026-07-01): +# Build (deploy/bootstrap.sh does this for you, with the URLs and digests +# deploy/game-stack.lock names): +# . <(grep -E '^(PAPER|LUCKPERMS)_' deploy/game-stack.lock) # 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')" \ +# --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ +# --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ +# --build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \ # -t felis-lobby:demo . # docker save felis-lobby:demo | sudo k3s ctr images import - # # felis.toml → [velocity] lobby_image = "felis-lobby:demo" @@ -28,7 +28,7 @@ # ---- build the felis-paper plugin jar (Paper API is Java 21) ---- # gradle:8.14-jdk21 — an official Gradle image on JDK 21 (this tree vendors no Gradle # wrapper, and a bare JDK image ships no `gradle`). JDK 21 matches the Paper API. -FROM gradle:8.14-jdk21 AS plugin +FROM gradle:8.14-jdk21@sha256:5c4c0c4284de4a19951e82ac78f86dbcda2e136644bbfe159beba7ea3420cc80 AS plugin WORKDIR /src COPY plugins/paper/ ./plugins/paper/ COPY plugins/shared/ ./plugins/shared/ @@ -41,7 +41,7 @@ RUN cd plugins/paper \ # 25-jre, not 21: Paper 26.2 declares `java.version.minimum = 25` (PaperMC Fill v3, # GET /v3/projects/paper/versions/26.2) and refuses to boot on anything older. A 25 JRE # also runs the plugin's Java-21 bytecode, so only the runtime moves. -FROM eclipse-temurin:25-jre +FROM eclipse-temurin:25-jre@sha256:bb036ed6cfdc57e3da7c22634d15f1b840d2caf76183861c80e81ca4b5104abb 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 @@ -51,10 +51,12 @@ ARG PAPER_JAR_SHA256 # (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. +# Failing the build is the cheap place to notice. Passed in rather than pinned here for +# the same reason PAPER_JAR_URL is: deploy/game-stack.lock names the build, so this file +# does not change on every LuckPerms release. The digest is required like Paper's; the +# jar runs inside the lobby with the server's full permissions. ARG LUCKPERMS_JAR_URL +ARG LUCKPERMS_JAR_SHA256 WORKDIR /paper RUN set -eu; \ if [ -z "${PAPER_JAR_URL:-}" ]; then \ @@ -66,11 +68,15 @@ RUN set -eu; \ if [ -z "${LUCKPERMS_JAR_URL:-}" ]; then \ echo "ERROR: --build-arg LUCKPERMS_JAR_URL= is required" >&2; exit 1; \ fi; \ + if [ -z "${LUCKPERMS_JAR_SHA256:-}" ]; then \ + echo "ERROR: --build-arg LUCKPERMS_JAR_SHA256= 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; \ + echo "$LUCKPERMS_JAR_SHA256 /paper/plugins/LuckPerms.jar" | sha256sum -c; \ 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 diff --git a/deploy/paper/Dockerfile b/deploy/paper/Dockerfile index 656b71e..7737a86 100644 --- a/deploy/paper/Dockerfile +++ b/deploy/paper/Dockerfile @@ -14,11 +14,11 @@ # image a user brings is made joinable the same way. If the initContainer is absent (no # FELIS_IMAGE configured) Paper boots as a standalone online server: degraded, not broken. # -# Build (deploy/bootstrap.sh does this for you; PAPER_JAR_URL comes from PaperMC's Fill v3 -# API — the SAME url the lobby build resolves, so this reuses it and adds no new dependency): +# Build (deploy/bootstrap.sh does this for you, with the SAME Paper build the lobby uses — +# deploy/game-stack.lock names it — so this adds no new dependency): +# . <(grep '^PAPER_' deploy/game-stack.lock) # 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= \ +# --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" --build-arg PAPER_JAR_SHA256="$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) @@ -29,7 +29,7 @@ # 25-jre, not 21: Paper 26.2 declares java.version.minimum=25 (PaperMC Fill v3) and refuses # to boot on anything older. -FROM eclipse-temurin:25-jre +FROM eclipse-temurin:25-jre@sha256:bb036ed6cfdc57e3da7c22634d15f1b840d2caf76183861c80e81ca4b5104abb 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 diff --git a/deploy/update-game-stack-lock.sh b/deploy/update-game-stack-lock.sh new file mode 100755 index 0000000..d3b25c4 --- /dev/null +++ b/deploy/update-game-stack-lock.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Refreshes deploy/game-stack.lock to upstream's newest builds, hashed. +# +# bash deploy/update-game-stack-lock.sh # rewrite the lock in place +# bash deploy/update-game-stack-lock.sh --check # exit 1 if upstream moved on +# +# It runs the same resolver bootstrap.sh uses for FELIS_GAME_STACK=latest (the functions are +# lifted out of bootstrap.sh, so the two cannot drift), then pins Velocity's newest build of +# VELOCITY_LATEST_MINOR. Limbo and LuckPerms publish no digest, so their jars are downloaded +# and hashed here; Paper and Velocity come from Fill's content-addressed URLs. +# +# Review the diff before committing: MC_VERSION moves the login gate's protocol, and the +# lobby, plain-Paper image and every client follow it. +set -Eeuo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BS="${here}/bootstrap.sh" +LOCK="${here}/game-stack.lock" +check=0 +case "${1:-}" in + --check) check=1 ;; + "") ;; + *) printf 'usage: %s [--check]\n' "$0" >&2; exit 2 ;; +esac + +log() { printf '[lock] %s\n' "$*" >&2; } +ok() { printf '[ ok ] %s\n' "$*" >&2; } +warn() { :; } +die() { printf '[fail] %s\n' "$*" >&2; exit 1; } + +lift() { # function-name + local body + body="$(awk -v f="$1" '$0 ~ "^" f "\\(\\) \\{" {on=1} on {print} on && /^}/ {exit}' "$BS")" + [ -n "$body" ] || die "bootstrap.sh no longer defines $1" + eval "$body" +} +for fn in meta_get papermc_latest_jar luckperms_latest_jar url_sha256 resolve_latest_game_jars; do + lift "$fn" +done +eval "$(grep '^VELOCITY_LATEST_MINOR=' "$BS")" +[ -n "${VELOCITY_LATEST_MINOR:-}" ] || die "bootstrap.sh no longer sets VELOCITY_LATEST_MINOR" + +resolve_latest_game_jars +log "resolving the newest Velocity ${VELOCITY_LATEST_MINOR} build" +velocity="$(papermc_latest_jar velocity "$VELOCITY_LATEST_MINOR")" \ + || die "no Velocity build for ${VELOCITY_LATEST_MINOR}" +VELOCITY_VERSION="$VELOCITY_LATEST_MINOR" +VELOCITY_JAR_URL="${velocity% *}" +VELOCITY_JAR_SHA256="${velocity##* }" + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +# The comment header is kept as it is; only the KEY=value lines are regenerated. +sed -n '/^#/p;/^#/!q' "$LOCK" > "$tmp" +for key in MC_VERSION LIMBO_VERSION LIMBO_JAR_URL LIMBO_JAR_SHA256 LIMBO_SCHEM_URL LIMBO_SCHEM_SHA256 \ + PAPER_JAR_URL PAPER_JAR_SHA256 LUCKPERMS_JAR_URL LUCKPERMS_JAR_SHA256 \ + VELOCITY_VERSION VELOCITY_JAR_URL VELOCITY_JAR_SHA256; do + printf '%s=%s\n' "$key" "${!key}" >> "$tmp" +done + +if cmp -s "$tmp" "$LOCK"; then + ok "game-stack.lock already pins upstream's newest builds" + exit 0 +fi +diff -u "$LOCK" "$tmp" >&2 || true +if [ "$check" = 1 ]; then + die "upstream has newer builds than game-stack.lock" +fi +cp "$tmp" "$LOCK" +ok "game-stack.lock updated; run go test . and the bootstrap tests, then commit" diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4c715b8..8bf867c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1324,6 +1324,9 @@ Two properties of the control plane matter when you do: | cloudflared (when absent) | release `FELIS_CLOUDFLARED_VERSION` (default `2026.9.1`) against a pinned sha256; another version needs `FELIS_CLOUDFLARED_SHA256` | | Go toolchain (nano, source builds) | pinned sha256 per architecture; another version needs `FELIS_GO_SHA256` | | the registry image | pinned by digest (`registry:2.8.3@sha256:a3d8…`) | +| Limbo, its spawn schematic, Paper, LuckPerms, Velocity | the builds and sha256s in `deploy/game-stack.lock`; each image build and the proxy install refuse a download that hashes differently (§15b) | +| the Temurin JRE the proxy runs on | release `25.0.4.1+1` against a pinned sha256 per architecture | +| base images of the felis, limbo, lobby and paper images | pinned by digest in each `Dockerfile` | On a public repository each release also carries a signed build-provenance attestation. Check a downloaded binary with @@ -1384,10 +1387,29 @@ took (§16, "Roll back an upgrade that broke the database"). ## 15b. Game images, pinned builds, and moving a world to a newer Minecraft +Each release pins the upstream builds it installs in `deploy/game-stack.lock`: +the Limbo CI build and its Minecraft version, the Paper build for that version, +LuckPerms and Velocity, each with its sha256. Every host installing one release +builds the same login gate, lobby and plain-Paper image, and a rerun of the same +release rebuilds nothing. Moving the stack to newer upstream builds is a release +change: `bash deploy/update-game-stack-lock.sh` resolves and hashes upstream's +newest builds and rewrites the lock (`--check` only reports whether upstream +moved on). Two installer knobs leave the lock: + +- `FELIS_GAME_STACK=latest` resolves upstream's newest builds at install time, + hashes the ones that publish no digest, and warns that they are not the + release's builds. +- `FELIS_VELOCITY_VERSION=` installs that minor's newest Velocity build + (content-addressed, so still sha256-checked). + +`FELIS_JRE_VERSION` picks the proxy's Java feature release (default 25, pinned +to Temurin `25.0.4.1+1`). A JRE the installer put there moves to the pinned +build on the next rerun; a JRE from any other vendor is left in place. + The platform's game images live under mutable tags -(`registry.felis.svc:5000/felis/paper:demo`): every installer run resolves the -newest Paper/Limbo release and pushes the new build over the same tag. A server -never follows that tag on its own. felis-api stores the image a server is +(`registry.felis.svc:5000/felis/paper:demo`): an installer run that builds a +different stack pushes the new build over the same tag. A server never follows +that tag on its own. felis-api stores the image a server is created with pinned to the digest the tag named at that moment (`…/felis/paper:demo@sha256:…`), and the installer's `pin_user_server_images` step pins any older server still on a bare tag *before* it pushes the new