feat(bootstrap): game stack 按 lock 文件固定构建并校验 sha256,基础镜像按 digest 固定,JRE 固定补丁版本

This commit is contained in:
Lemon-miaow committed 2026-09-25 00:24:02 +08:00
1 parent aace66e8d3
commit 82545548e7
13 files changed
+627 -89

No files matched your search

+17 -2
View File
@@ -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"]
+7 -3
View File
@@ -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
+1
View File
@@ -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
+108
View File
@@ -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)
+1 -1
View File
@@ -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=<minor> 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,
},
{
+218 -42
View File
@@ -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/<id> 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-<limbo-ver>-<mc-ver>.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-<limbo-ver>-<mc-ver>.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
local arch want url release json
case "$(uname -m)" in
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
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}" ;;
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)"
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##* }"
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
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
+115 -15
View File
@@ -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
+23
View File
@@ -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
+20 -7
View File
@@ -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/<n>/artifact/target/Limbo-<ver>.jar \
# --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/spawn.schem \
# --build-arg LIMBO_VERSION=<maven-api-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=<Limbo server jar> is required" >&2; exit 1; \
fi; \
if [ -z "${LIMBO_JAR_SHA256:-}" ]; then \
echo "ERROR: --build-arg LIMBO_JAR_SHA256=<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=<spawn.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
+17 -11
View File
@@ -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/<sha>/paper-26.2-<build>.jar \
# --build-arg PAPER_JAR_SHA256=<that same sha — the objects/ path segment> \
# --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=<luckperms bukkit jar> is required" >&2; exit 1; \
fi; \
if [ -z "${LUCKPERMS_JAR_SHA256:-}" ]; then \
echo "ERROR: --build-arg LUCKPERMS_JAR_SHA256=<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
+5 -5
View File
@@ -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/<sha>/paper-<ver>-<build>.jar \
# --build-arg PAPER_JAR_SHA256=<that same sha — the objects/ path segment> \
# --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
+70
View File
@@ -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"
+25 -3
View File
@@ -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=<minor>` 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