feat(bootstrap): game stack 按 lock 文件固定构建并校验 sha256,基础镜像按 digest 固定,JRE 固定补丁版本
This commit is contained in:
13 files changed
+627
-89
No files matched your search
+17
-2
@@ -1,8 +1,23 @@
|
|||||||
# The workflows pin every action to a commit SHA. This keeps those pins moving: Dependabot
|
# The workflows pin every action to a commit SHA, and every Dockerfile pins its base images
|
||||||
# reads the "# vX.Y.Z" comment next to each SHA and opens a PR that bumps both together.
|
# 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
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
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
@@ -21,14 +21,18 @@
|
|||||||
# minutes. The FINAL stage is deliberately NOT pinned — it must stay on the target platform
|
# 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
|
# 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.
|
# 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
|
WORKDIR /panel
|
||||||
COPY panel/package*.json ./
|
COPY panel/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY panel/ ./
|
COPY panel/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.26 AS build
|
FROM --platform=$BUILDPLATFORM golang:1.26@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ARG TARGETOS=linux
|
ARG TARGETOS=linux
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -55,7 +59,7 @@ ARG FELIS_VERSION=dev
|
|||||||
RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \
|
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
|
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
|
ENV PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis
|
COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis
|
||||||
# distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins
|
# distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ var bootstrapAssets embed.FS
|
|||||||
// otherwise be baked into every felis binary. Keep them explicit — add a source
|
// otherwise be baked into every felis binary. Keep them explicit — add a source
|
||||||
// directory here, never a parent.
|
// directory here, never a parent.
|
||||||
//
|
//
|
||||||
|
//go:embed deploy/game-stack.lock
|
||||||
//go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh
|
//go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh
|
||||||
//go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh
|
//go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh
|
||||||
//go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh
|
//go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package felis
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 {
|
func readGameStackFile(t *testing.T, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := gameStackAssets.ReadFile(name)
|
b, err := gameStackAssets.ReadFile(name)
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ var updateTargets = []updateTarget{
|
|||||||
{
|
{
|
||||||
selector: "velocity",
|
selector: "velocity",
|
||||||
component: "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,
|
command: installerRerun,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+218
-42
@@ -46,6 +46,14 @@
|
|||||||
# proxy instead of the stock download (default: unset, stock).
|
# proxy instead of the stock download (default: unset, stock).
|
||||||
# FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar
|
# FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar
|
||||||
# above is set; the install refuses on a mismatch.
|
# 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_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.
|
# 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.
|
# 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
|
# server — forwarding is applied by the operator's init-forwarding initContainer, so it
|
||||||
# needs no secret. Seeded recommended in 0019_recommended_paper.sql.
|
# needs no secret. Seeded recommended in 0019_recommended_paper.sql.
|
||||||
FELIS_PAPER_IMAGE="${FELIS_PAPER_IMAGE:-${REGISTRY_URL}/felis/paper:demo}"
|
FELIS_PAPER_IMAGE="${FELIS_PAPER_IMAGE:-${REGISTRY_URL}/felis/paper:demo}"
|
||||||
# The Velocity MINOR is pinned, not discovered. PaperMC's Fill v3 groups velocity
|
# The Velocity build comes from deploy/game-stack.lock (VELOCITY_VERSION and its jar digest).
|
||||||
# builds by version group, and "newest across all groups" today means 4.0.0-SNAPSHOT —
|
# Setting FELIS_VELOCITY_VERSION to another version, or FELIS_GAME_STACK=latest, installs
|
||||||
# an UNRELEASED proxy (the 4.0.0 group has zero published builds) that needs a Java 25
|
# the newest BUILD of that minor instead. The minor itself is never discovered: PaperMC's
|
||||||
# runtime. Crossing a major is a deliberate code change, so we track the newest BUILD of
|
# Fill v3 groups velocity builds by version group, and "newest across all groups" can mean
|
||||||
# a pinned minor and let a human move the pin.
|
# an unreleased 4.x SNAPSHOT that needs another runtime. Crossing a major is a deliberate
|
||||||
FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-3.5.1}"
|
# 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
|
# Path to a Felis-Legacy Velocity fork build, installed as the proxy in place of the
|
||||||
# stock download. Unset — the default — changes nothing.
|
# 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
|
# a lottery across four package managers — a tarball is one code path everywhere (same
|
||||||
# reasoning as install_go_toolchain).
|
# reasoning as install_go_toolchain).
|
||||||
FELIS_JRE_VERSION="${FELIS_JRE_VERSION:-25}"
|
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
|
# 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
|
# are ClusterIP-only, verify the modern-forwarding HMAC, and use NetworkPolicy to limit
|
||||||
# non-node ingress to the declared proxy CIDRs.
|
# non-node ingress to the declared proxy CIDRs.
|
||||||
@@ -671,6 +692,10 @@ validate_settings() {
|
|||||||
validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN"
|
validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN"
|
||||||
validate_cidr FELIS_NANO_PROXY_CIDR "$FELIS_NANO_PROXY_CIDR"
|
validate_cidr FELIS_NANO_PROXY_CIDR "$FELIS_NANO_PROXY_CIDR"
|
||||||
validate_offsite_settings
|
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
|
# validate_offsite_settings checks the FELIS_OFFSITE_* inputs before anything is
|
||||||
@@ -1198,7 +1223,7 @@ github_api() {
|
|||||||
# can never disagree about what "latest" means.
|
# can never disagree about what "latest" means.
|
||||||
github_latest_tag() {
|
github_latest_tag() {
|
||||||
local json 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
|
json="$(github_api "repos/$(repo_slug)/releases/latest")" || return 1
|
||||||
tag="$(printf '%s' "$json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' || true)"
|
tag="$(printf '%s' "$json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' || true)"
|
||||||
tag="${tag%%$'\n'*}"
|
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.
|
# reachable this way — fetch releases/assets/<id> with the JSON Accept if that is ever needed.
|
||||||
github_asset_id() {
|
github_asset_id() {
|
||||||
local tag="$1" name="$2" json 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
|
json="$(github_api "repos/$(repo_slug)/releases/tags/${tag}")" || return 1
|
||||||
id="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' \
|
id="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' \
|
||||||
| grep "\"name\":[[:space:]]*\"${name}\"" \
|
| grep "\"name\":[[:space:]]*\"${name}\"" \
|
||||||
@@ -1685,11 +1710,6 @@ game_stack_source() {
|
|||||||
ok "game-stack sources unpacked to ${GAME_STACK_DIR}"
|
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
|
# 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
|
# 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
|
# a flaky IPv6 path) is outside its retry set, so the outer loop retries every failure
|
||||||
@@ -1710,8 +1730,71 @@ meta_get() {
|
|||||||
return 1
|
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() {
|
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"
|
log "resolving the newest LOOHP/Limbo CI build"
|
||||||
# Fetch first, filter second: `curl | grep | head` dies of SIGPIPE under `set -o pipefail`
|
# 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.
|
# 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="$(printf '%s' "$meta" | grep -o 'Limbo-[0-9A-Za-z._-]*\.jar' || true)"
|
||||||
file="${file%%$'\n'*}"
|
file="${file%%$'\n'*}"
|
||||||
[ -n "$file" ] || die "no Limbo jar in the LOOHP/Limbo CI artifact list"
|
[ -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
|
base="${file%.jar}" # Limbo-2026.0.2-ALPHA-26.2
|
||||||
MC_VERSION="${base##*-}" # 26.2
|
MC_VERSION="${base##*-}" # 26.2
|
||||||
@@ -1742,7 +1832,16 @@ resolve_game_jars() {
|
|||||||
log "resolving the newest LuckPerms build"
|
log "resolving the newest LuckPerms build"
|
||||||
LUCKPERMS_JAR_URL="$(luckperms_latest_jar)" \
|
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"
|
|| 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.
|
# 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})"
|
log "building ${FELIS_LIMBO_IMAGE} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
|
||||||
docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
|
docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
|
||||||
--build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \
|
--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_URL="$LIMBO_SCHEM_URL" \
|
||||||
|
--build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \
|
||||||
--build-arg LIMBO_VERSION="$LIMBO_VERSION" \
|
--build-arg LIMBO_VERSION="$LIMBO_VERSION" \
|
||||||
-t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR"
|
-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_URL="$PAPER_JAR_URL" \
|
||||||
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
||||||
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
|
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
|
||||||
|
--build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \
|
||||||
-t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR"
|
-t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR"
|
||||||
|
|
||||||
# Plain Paper, same MC_VERSION and PAPER_JAR_URL (no new dependency). Forwarding is the
|
# 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() {
|
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
|
if [ -x "${JRE_DIR}/bin/java" ]; then
|
||||||
ok "JRE already installed at ${JRE_DIR}"
|
ok "JRE already installed at ${JRE_DIR}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
case "$(uname -m)" in
|
die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}"
|
||||||
x86_64|amd64) arch="x64" ;;
|
;;
|
||||||
aarch64|arm64) arch="aarch64" ;;
|
|
||||||
*) die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}" ;;
|
|
||||||
esac
|
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}"
|
if [ "$FELIS_JRE_VERSION" = "$JRE_PINNED_FEATURE" ]; then
|
||||||
local tmp
|
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)"
|
tmp="$(mktemp -d)"
|
||||||
remember_temp "$tmp"
|
remember_temp "$tmp"
|
||||||
curl -fsSL "$url" -o "${tmp}/jre.tar.gz" || die "failed to download the Temurin JRE: ${url}"
|
curl -fsSL --retry 5 --retry-delay 2 "$url" -o "${tmp}/jre.tar.gz" \
|
||||||
mkdir -p "$JRE_DIR"
|
|| 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 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.
|
# 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"
|
tar -C "${JRE_DIR}.new" --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"
|
[ -x "${JRE_DIR}.new/bin/java" ] || die "unpacked JRE has no bin/java"
|
||||||
ok "JRE at ${JRE_DIR}/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() {
|
install_velocity() {
|
||||||
@@ -2039,22 +2196,18 @@ install_velocity() {
|
|||||||
log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR} (sha256 ${have})"
|
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
|
atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root
|
||||||
else
|
else
|
||||||
log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build"
|
local version="${FELIS_VELOCITY_VERSION:-${VELOCITY_VERSION:-$VELOCITY_LATEST_MINOR}}"
|
||||||
resolved="$(papermc_latest_jar velocity "$FELIS_VELOCITY_VERSION")" \
|
if [ "$FELIS_GAME_STACK" = "pinned" ] && [ "$version" = "${VELOCITY_VERSION:-}" ]; then
|
||||||
|| die "no Velocity build for ${FELIS_VELOCITY_VERSION} (override with FELIS_VELOCITY_VERSION)"
|
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% *}"
|
url="${resolved% *}"
|
||||||
want="${resolved##* }"
|
want="${resolved##* }"
|
||||||
log "downloading Velocity ${FELIS_VELOCITY_VERSION}"
|
fi
|
||||||
tmp="$(mktemp "${VELOCITY_DIR}/.velocity.jar.XXXXXX")"
|
stage_velocity_jar "$url" "$want" "$version"
|
||||||
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
|
fi
|
||||||
|
|
||||||
install_via_plugins
|
install_via_plugins
|
||||||
@@ -2063,6 +2216,29 @@ install_velocity() {
|
|||||||
configure_velocity_firewall
|
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
|
# 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
|
# 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
|
# the host netns) — the same trick the on-node break-glass console uses. The internal face is
|
||||||
|
|||||||
+115
-15
@@ -120,32 +120,27 @@ out="$(bash -c '
|
|||||||
')"
|
')"
|
||||||
expect "meta_get gives up after three attempts" "GAVE UP" "$out"
|
expect "meta_get gives up after three attempts" "GAVE UP" "$out"
|
||||||
|
|
||||||
# --- the resolved-Velocity digest gate --------------------------------------------------
|
# --- the Velocity digest gate -----------------------------------------------------------
|
||||||
# The download must hash to what the content-addressed URL promised, BEFORE
|
# 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.
|
# 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
|
vblock="$(awk '/^stage_velocity_jar\(\) \{/,/^}/' "$BS")"
|
||||||
# and mawk but undefined in POSIX awk, and CI's awk is whatever ubuntu ships.
|
[ -n "$vblock" ] || { echo "FAIL: no stage_velocity_jar found in $BS"; exit 1; }
|
||||||
vblock="$(awk '/log "resolving the newest Velocity/,/atomic_install_file "\$tmp" "\$.VELOCITY_DIR.\/velocity\.jar"/' "$BS")"
|
|
||||||
[ -n "$vblock" ] || { echo "FAIL: no resolved-Velocity install block found in $BS"; exit 1; }
|
|
||||||
[ "$(printf '%s\n' "$vblock" | wc -l)" -lt 30 ] \
|
|
||||||
|| { echo "FAIL: the extracted block is not the velocity install -- did its last line move?"; exit 1; }
|
|
||||||
|
|
||||||
vdir="$(mktemp -d)"
|
vdir="$(mktemp -d)"
|
||||||
trap 'rm -f "$jar"; rm -rf "$vdir"' EXIT
|
trap 'rm -f "$jar"; rm -rf "$vdir"' EXIT
|
||||||
vwant="$(printf 'stand-in velocity build\n' | sha256sum | cut -d' ' -f1)"
|
vwant="$(printf 'stand-in velocity build\n' | sha256sum | cut -d' ' -f1)"
|
||||||
|
|
||||||
run_velocity_install() { # digest-the-resolver-reports
|
run_velocity_install() { # expected-digest
|
||||||
WANT="$1" VELOCITY_DIR="$vdir" FELIS_VELOCITY_VERSION=3.5.1 bash -c '
|
WANT="$1" VELOCITY_DIR="$vdir" bash -c '
|
||||||
die() { printf "DIE: %s\n" "$*"; exit 1; }
|
die() { printf "DIE: %s\n" "$*"; exit 1; }
|
||||||
log() { printf "LOG: %s\n" "$*"; }
|
log() { printf "LOG: %s\n" "$*"; }
|
||||||
|
ok() { printf "OK: %s\n" "$*"; }
|
||||||
remember_temp() { :; }
|
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"; }
|
curl() { while [ "$#" -gt 1 ] && [ "$1" != "-o" ]; do shift; done; printf "stand-in velocity build\n" > "$2"; }
|
||||||
atomic_install_file() { printf "INSTALL: %s\n" "$2"; }
|
atomic_install_file() { printf "INSTALL: %s\n" "$2"; cp "$1" "$2"; }
|
||||||
'"$vblock"
|
'"$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)"
|
out="$(run_velocity_install deadbeef)"
|
||||||
@@ -158,6 +153,111 @@ esac
|
|||||||
|
|
||||||
out="$(run_velocity_install "$vwant")"
|
out="$(run_velocity_install "$vwant")"
|
||||||
expect "the matching download installs" "INSTALL: ${vdir}/velocity.jar" "$out"
|
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 -----------------------------------------------------
|
# --- [[auth_source]] carry-forward -----------------------------------------------------
|
||||||
# write_felis_toml regenerates felis.toml wholesale on every run; this is what keeps the
|
# write_felis_toml regenerates felis.toml wholesale on every run; this is what keeps the
|
||||||
|
|||||||
@@ -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
@@ -10,10 +10,11 @@
|
|||||||
# There is no bundled server.properties — Limbo writes a default on first run.
|
# 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:
|
# 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 \
|
# 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_JAR_URL="$LIMBO_JAR_URL" --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
|
||||||
# --build-arg LIMBO_SCHEM_URL=https://ci.loohpjames.com/job/Limbo/<n>/artifact/spawn.schem \
|
# --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" --build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \
|
||||||
# --build-arg LIMBO_VERSION=<maven-api-version> \
|
# --build-arg LIMBO_VERSION="$LIMBO_VERSION" \
|
||||||
# -t felis-limbo:demo .
|
# -t felis-limbo:demo .
|
||||||
#
|
#
|
||||||
# Note LIMBO_VERSION (the maven API version the plugin compiles against, e.g.
|
# 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
|
# 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).
|
# 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+.
|
# 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
|
WORKDIR /src
|
||||||
# Copy what the limbo module needs: its own tree plus the shared link core it
|
# 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
|
# 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
|
# 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
|
# JRE cannot run it (UnsupportedClassVersionError). A 21 JRE also runs the
|
||||||
# plugin's release-17 bytecode fine.
|
# 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_URL
|
||||||
|
ARG LIMBO_JAR_SHA256
|
||||||
ARG LIMBO_SCHEM_URL
|
ARG LIMBO_SCHEM_URL
|
||||||
|
ARG LIMBO_SCHEM_SHA256
|
||||||
WORKDIR /limbo
|
WORKDIR /limbo
|
||||||
# Pull the two loose LOOHP/Limbo CI artifacts: the server jar (required, saved as
|
# 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
|
# Limbo.jar) and the default spawn schematic (optional). Each is checked against the
|
||||||
# URL was not supplied.
|
# 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; \
|
RUN set -eu; \
|
||||||
if [ -z "${LIMBO_JAR_URL:-}" ]; then \
|
if [ -z "${LIMBO_JAR_URL:-}" ]; then \
|
||||||
echo "ERROR: --build-arg LIMBO_JAR_URL=<Limbo server jar> is required" >&2; exit 1; \
|
echo "ERROR: --build-arg LIMBO_JAR_URL=<Limbo server jar> is required" >&2; exit 1; \
|
||||||
fi; \
|
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; \
|
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \
|
||||||
curl -fSL "$LIMBO_JAR_URL" -o /limbo/Limbo.jar; \
|
curl -fSL "$LIMBO_JAR_URL" -o /limbo/Limbo.jar; \
|
||||||
|
echo "$LIMBO_JAR_SHA256 /limbo/Limbo.jar" | sha256sum -c; \
|
||||||
if [ -n "${LIMBO_SCHEM_URL:-}" ]; then \
|
if [ -n "${LIMBO_SCHEM_URL:-}" ]; then \
|
||||||
curl -fSL "$LIMBO_SCHEM_URL" -o /limbo/spawn.schem; \
|
curl -fSL "$LIMBO_SCHEM_URL" -o /limbo/spawn.schem; \
|
||||||
|
echo "$LIMBO_SCHEM_SHA256 /limbo/spawn.schem" | sha256sum -c; \
|
||||||
fi; \
|
fi; \
|
||||||
apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
|
apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
|
||||||
mkdir -p /limbo/plugins
|
mkdir -p /limbo/plugins
|
||||||
|
|||||||
+17
-11
@@ -5,13 +5,13 @@
|
|||||||
# the POST-auth /menu hub: it is reached only when the login gate transfers an
|
# 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.
|
# 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
|
# Build (deploy/bootstrap.sh does this for you, with the URLs and digests
|
||||||
# Fill v3 API — api.papermc.io v2 has returned HTTP 410 since 2026-07-01):
|
# deploy/game-stack.lock names):
|
||||||
|
# . <(grep -E '^(PAPER|LUCKPERMS)_' deploy/game-stack.lock)
|
||||||
# docker build -f deploy/lobby/Dockerfile \
|
# 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_URL="$PAPER_JAR_URL" --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
||||||
# --build-arg PAPER_JAR_SHA256=<that same sha — the objects/ path segment> \
|
# --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
|
||||||
# --build-arg LUCKPERMS_JAR_URL="$(curl -fsSL https://metadata.luckperms.net/data/all \
|
# --build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \
|
||||||
# | grep -o 'https://download.luckperms.net/[^"]*/bukkit/loader/[^"]*\.jar')" \
|
|
||||||
# -t felis-lobby:demo .
|
# -t felis-lobby:demo .
|
||||||
# docker save felis-lobby:demo | sudo k3s ctr images import -
|
# docker save felis-lobby:demo | sudo k3s ctr images import -
|
||||||
# # felis.toml → [velocity] lobby_image = "felis-lobby:demo"
|
# # felis.toml → [velocity] lobby_image = "felis-lobby:demo"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
# ---- build the felis-paper plugin jar (Paper API is Java 21) ----
|
# ---- 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
|
# 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.
|
# 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
|
WORKDIR /src
|
||||||
COPY plugins/paper/ ./plugins/paper/
|
COPY plugins/paper/ ./plugins/paper/
|
||||||
COPY plugins/shared/ ./plugins/shared/
|
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,
|
# 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
|
# 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.
|
# 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
|
ARG PAPER_JAR_URL
|
||||||
# Required alongside the URL: Fill's URLs are content-addressed, but nothing enforces
|
# 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
|
# 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
|
# (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
|
# 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.
|
# 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
|
# Failing the build is the cheap place to notice. Passed in rather than pinned here for
|
||||||
# here for the same reason PAPER_JAR_URL is: bootstrap.sh asks upstream for the current
|
# the same reason PAPER_JAR_URL is: deploy/game-stack.lock names the build, so this file
|
||||||
# build, so this file does not go stale on every LuckPerms release.
|
# 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_URL
|
||||||
|
ARG LUCKPERMS_JAR_SHA256
|
||||||
WORKDIR /paper
|
WORKDIR /paper
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
if [ -z "${PAPER_JAR_URL:-}" ]; then \
|
if [ -z "${PAPER_JAR_URL:-}" ]; then \
|
||||||
@@ -66,11 +68,15 @@ RUN set -eu; \
|
|||||||
if [ -z "${LUCKPERMS_JAR_URL:-}" ]; then \
|
if [ -z "${LUCKPERMS_JAR_URL:-}" ]; then \
|
||||||
echo "ERROR: --build-arg LUCKPERMS_JAR_URL=<luckperms bukkit jar> is required" >&2; exit 1; \
|
echo "ERROR: --build-arg LUCKPERMS_JAR_URL=<luckperms bukkit jar> is required" >&2; exit 1; \
|
||||||
fi; \
|
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; \
|
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \
|
||||||
mkdir -p /paper/plugins; \
|
mkdir -p /paper/plugins; \
|
||||||
curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \
|
curl -fSL "$PAPER_JAR_URL" -o /paper/paper.jar; \
|
||||||
echo "$PAPER_JAR_SHA256 /paper/paper.jar" | sha256sum -c; \
|
echo "$PAPER_JAR_SHA256 /paper/paper.jar" | sha256sum -c; \
|
||||||
curl -fSL "$LUCKPERMS_JAR_URL" -o /paper/plugins/LuckPerms.jar; \
|
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/*; \
|
apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*; \
|
||||||
echo "eula=true" > /paper/eula.txt
|
echo "eula=true" > /paper/eula.txt
|
||||||
COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar
|
COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
# image a user brings is made joinable the same way. If the initContainer is absent (no
|
# 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.
|
# 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
|
# Build (deploy/bootstrap.sh does this for you, with the SAME Paper build the lobby uses —
|
||||||
# API — the SAME url the lobby build resolves, so this reuses it and adds no new dependency):
|
# deploy/game-stack.lock names it — so this adds no new dependency):
|
||||||
|
# . <(grep '^PAPER_' deploy/game-stack.lock)
|
||||||
# docker build -f deploy/paper/Dockerfile \
|
# 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_URL="$PAPER_JAR_URL" --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
||||||
# --build-arg PAPER_JAR_SHA256=<that same sha — the objects/ path segment> \
|
|
||||||
# -t felis-paper:demo .
|
# -t felis-paper:demo .
|
||||||
# docker save felis-paper:demo | sudo k3s ctr images import -
|
# docker save felis-paper:demo | sudo k3s ctr images import -
|
||||||
# # felis.toml → recommended via 0019_recommended_paper.sql (no [velocity] key points here)
|
# # 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
|
# 25-jre, not 21: Paper 26.2 declares java.version.minimum=25 (PaperMC Fill v3) and refuses
|
||||||
# to boot on anything older.
|
# to boot on anything older.
|
||||||
FROM eclipse-temurin:25-jre
|
FROM eclipse-temurin:25-jre@sha256:bb036ed6cfdc57e3da7c22634d15f1b840d2caf76183861c80e81ca4b5104abb
|
||||||
ARG PAPER_JAR_URL
|
ARG PAPER_JAR_URL
|
||||||
# Required alongside the URL: Fill's URLs are content-addressed, but nothing enforces
|
# 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
|
# that shape at build time. Checking the digest after the download turns a truncated or
|
||||||
|
|||||||
Executable
+70
@@ -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
@@ -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` |
|
| 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` |
|
| 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…`) |
|
| 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
|
On a public repository each release also carries a signed build-provenance
|
||||||
attestation. Check a downloaded binary with
|
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
|
## 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
|
The platform's game images live under mutable tags
|
||||||
(`registry.felis.svc:5000/felis/paper:demo`): every installer run resolves the
|
(`registry.felis.svc:5000/felis/paper:demo`): an installer run that builds a
|
||||||
newest Paper/Limbo release and pushes the new build over the same tag. A server
|
different stack pushes the new build over the same tag. A server never follows
|
||||||
never follows that tag on its own. felis-api stores the image a server is
|
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
|
created with pinned to the digest the tag named at that moment
|
||||||
(`…/felis/paper:demo@sha256:…`), and the installer's `pin_user_server_images`
|
(`…/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
|
step pins any older server still on a bare tag *before* it pushes the new
|
||||||
|
|||||||
Reference in new issue
Block a user