diff --git a/deploy/limbo/Dockerfile b/deploy/limbo/Dockerfile index 6d8a721..250da10 100644 --- a/deploy/limbo/Dockerfile +++ b/deploy/limbo/Dockerfile @@ -70,17 +70,23 @@ RUN set -eu; \ mkdir -p /limbo/plugins # Drop the login+readiness plugin in beside Limbo.jar. COPY --from=plugin /felis-limbo.jar /limbo/plugins/felis-limbo.jar -# The entrypoint pins the game port to the operator's GamePort before launching Limbo. +# The entrypoint pins the game port AND enables Velocity modern forwarding — refusing to +# start without the secret, because a login gate that derives offline UUIDs would let +# anyone claim any Minecraft identity (the Owner's included). COPY deploy/limbo/entrypoint.sh /usr/local/bin/felis-entrypoint.sh +# The operator mounts the world PVC at /data. Runtime state lives there; /limbo +# remains the immutable image seed copied into the volume by the entrypoint. +WORKDIR /data + ENV FELIS_HEALTH_PORT=8080 # FELIS_GAME_PORT is the port the entrypoint pins Limbo to; it MUST equal the operator's # GamePort (internal/operator/builders.go). Default 25565 — override only in lockstep # with the operator. ENV FELIS_GAME_PORT=25565 EXPOSE 25565 8080 -# felis-entrypoint.sh pins server-port then execs `java -jar Limbo.jar --nogui` (headless: -# the pod has no console). Limbo writes the rest of server.properties on first run and -# loads ./spawn.schem as the spawn world. Invoked via `sh` so no +x bit is needed from the -# (Windows) build host. +# felis-entrypoint.sh pins server-port + velocity-modern/forwarding-secrets, then execs +# `java -jar Limbo.jar --nogui` from /data (headless: the pod has no console). Limbo writes +# the rest of server.properties on the persistent volume and loads ./spawn.schem as the +# spawn world. Invoked via `sh` so no +x bit is needed from the (Windows) build host. ENTRYPOINT ["/bin/sh", "/usr/local/bin/felis-entrypoint.sh"] diff --git a/deploy/limbo/README.md b/deploy/limbo/README.md index 49badbf..75edab4 100644 --- a/deploy/limbo/README.md +++ b/deploy/limbo/README.md @@ -142,6 +142,6 @@ set them by hand: or a control-namespace ingress fence, it must also open the login-pod → felis-api-internal (8081) path. -The Velocity default-landing and waiting-park wiring is printed by `felis setup` -and enforces the invariant: fresh connections hit `login` first; nothing falls -back to the lobby. +The Velocity gate/lobby wiring is printed by `felis setup` and enforces the +invariant: fresh connections hit `login` first, and only an authenticated release +from that gate can enter the post-auth lobby or a remembered user backend. diff --git a/deploy/limbo/entrypoint.sh b/deploy/limbo/entrypoint.sh index 497d7df..4af42d4 100644 --- a/deploy/limbo/entrypoint.sh +++ b/deploy/limbo/entrypoint.sh @@ -1,31 +1,87 @@ #!/bin/sh # Felis login-limbo entrypoint. # -# Pin Limbo's game port to the pod-facing port the operator contract uses. LOOHP/Limbo -# defaults server-port to 30000, but the Felis operator drives everything — the Service -# Port/TargetPort, the TCP/HTTP readiness probe, the container port and the Velocity -# NetworkPolicy — off a single GamePort const (25565). A backend that bound 30000 would -# be unreachable through that fence. Limbo writes a full server.properties on first run -# and merges any partial we leave in place, so seeding/patching just server-port here is -# enough; the spawn schematic still loads from ./spawn.schem. +# Two things must be true before Limbo accepts a connection, and both are settings +# Limbo writes into server.properties on first run: # -# Idempotent by design: it runs on every start and rewrites only the server-port line, -# so a persisted world volume that already carries a server.properties keeps all its -# other settings. +# server-port — pinned to the pod-facing port the operator contract uses. LOOHP/Limbo +# defaults it to 30000, but the Felis operator drives everything (the +# Service Port/TargetPort, the readiness probe, the container port and +# the Velocity NetworkPolicy) off a single GamePort const (25565). A +# backend that bound 30000 would be unreachable through that fence. +# +# velocity-modern — Velocity modern player-info forwarding. This is the ONLY reason the +# forwarding-secrets login gate can be trusted to know WHO joined. With it on, Limbo +# verifies the proxy's HMAC over the login payload and takes the +# player's UUID from that signed payload (ClientConnection.java: +# validateVelocityModernResponse → getVelocityDataFrom → new Player(..., +# data.getUuid())). With it off, Limbo derives an OFFLINE UUID from the +# username — and the felis-limbo plugin would then mint a /link code +# bound to the WRONG Minecraft identity. The Owner IS a Minecraft +# account, claimed by joining this gate, so that is account takeover, +# not a cosmetic bug. +# +# Hence: NO SECRET, NO START. Refusing to boot is the safe failure — the operator marks the +# server Failed, `felis setup` surfaces the reason and stops before asking for a link code. +# A limbo that came up in offline mode would look perfectly healthy while handing out +# forgeable identities. forwarding-secrets is Limbo's ';'-separated list; Felis writes one. +# +# Idempotent by design: it runs on every start and rewrites only the keys below, so a +# persisted volume that already carries a server.properties keeps its other settings (and +# the spawn schematic still loads from ./spawn.schem). set -eu PORT="${FELIS_GAME_PORT:-25565}" +SECRET="${FELIS_FORWARDING_SECRET:-}" +RUNTIME_DIR="/limbo" +DATA_DIR="/data" PROPS="server.properties" -if [ -f "$PROPS" ]; then - if grep -q '^server-port=' "$PROPS"; then - sed -i "s/^server-port=.*/server-port=${PORT}/" "$PROPS" - else - printf 'server-port=%s\n' "$PORT" >> "$PROPS" - fi -else - printf 'server-port=%s\n' "$PORT" > "$PROPS" +if [ -z "$SECRET" ]; then + echo "felis-limbo: FATAL — FELIS_FORWARDING_SECRET is empty." >&2 + echo " The login gate authenticates the Owner, so it must not run without Velocity modern" >&2 + echo " forwarding: an unverified UUID would let anyone claim any Minecraft identity." >&2 + echo " Provision the secret with deploy/bootstrap.sh, then re-run 'sudo felis setup'." >&2 + exit 1 fi -echo "felis-limbo: pinned server-port=${PORT} (operator GamePort)" -exec java -jar Limbo.jar --nogui "$@" +# Keep mutable server state on the operator-mounted PVC. Refresh code artifacts on +# every boot so an image upgrade takes effect without replacing worlds or config. +mkdir -p "$DATA_DIR/plugins" +cp -f "$RUNTIME_DIR/Limbo.jar" "$DATA_DIR/Limbo.jar" +cp -f "$RUNTIME_DIR/plugins/felis-limbo.jar" "$DATA_DIR/plugins/felis-limbo.jar" +if [ -f "$RUNTIME_DIR/spawn.schem" ] && [ ! -f "$DATA_DIR/spawn.schem" ]; then + cp "$RUNTIME_DIR/spawn.schem" "$DATA_DIR/spawn.schem" +fi +cd "$DATA_DIR" + +# set_prop KEY VALUE — replace the key's line, or append it if absent. +set_prop() { + if [ -f "$PROPS" ] && grep -q "^$1=" "$PROPS"; then + # The secret is base64/hex-ish, but a '/' or '&' would still break a bare sed s///. + # '|' as the delimiter plus escaping it is enough for every value we write. + esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g') + sed -i "s|^$1=.*|$1=${esc}|" "$PROPS" + else + printf '%s=%s\n' "$1" "$2" >> "$PROPS" + fi +} + +set_prop server-port "$PORT" +# velocity-modern is mutually exclusive with the two legacy schemes in Limbo's own +# check — pin them off so a stale persisted properties file cannot silently downgrade +# the gate to a forwarding mode that carries no signature at all. +set_prop bungeecord false +set_prop bungee-guard false +set_prop velocity-modern true +set_prop forwarding-secrets "$SECRET" + +echo "felis-limbo: server-port=${PORT}, velocity-modern=true (forwarding secret loaded, UUIDs are Mojang-verified)" +JAVA_MEMORY_ARG="" +if [ -n "${JAVA_MEMORY:-}" ]; then + JAVA_MEMORY_ARG="-Xmx${JAVA_MEMORY}" +fi +set -f +# JAVA_FLAGS is emitted by the operator as a whitespace-separated JVM argument list. +# shellcheck disable=SC2086 +exec java $JAVA_MEMORY_ARG ${JAVA_FLAGS:-} -jar Limbo.jar --nogui "$@" diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 09cc548..74cc56e 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -5,16 +5,22 @@ # 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: +# 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): # docker build -f deploy/lobby/Dockerfile \ -# --build-arg PAPER_JAR_URL=https:///paper-1.21.x-.jar \ +# --build-arg PAPER_JAR_URL=https://fill-data.papermc.io/v1/objects//paper-26.2-.jar \ # -t felis-lobby:demo . # docker save felis-lobby:demo | sudo k3s ctr images import - # # felis.toml → [velocity] lobby_image = "felis-lobby:demo" # -# Contract: the game server listens on 25565 (the CRD GamePort). The lobby speaks -# only the felis:control plugin-message channel (spec §12) — it holds no felis-api -# token. +# The Paper version must match the LOGIN gate's: LOOHP/Limbo speaks exactly ONE protocol +# per build (its SERVER_IMPLEMENTATION_VERSION), and a client has to satisfy both hops. +# +# Contract: the game server listens on 25565 (the CRD GamePort). The lobby speaks only the +# felis:control plugin-message channel (spec §12) — it holds no felis-api token. It DOES +# receive FELIS_FORWARDING_SECRET (operator-injected from the felis-forwarding-secret +# Secret) and refuses to start without it: a lobby that cannot verify the proxy's signed +# handshake would trust an offline, forgeable UUID. # ---- 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 @@ -29,7 +35,10 @@ RUN cd plugins/paper \ && cp build/libs/*.jar /felis-paper.jar # ---- assemble the runtime ---- -FROM eclipse-temurin:21-jre +# 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 ARG PAPER_JAR_URL WORKDIR /paper RUN set -eu; \ @@ -42,8 +51,21 @@ RUN set -eu; \ mkdir -p /paper/plugins; \ echo "eula=true" > /paper/eula.txt COPY --from=plugin /felis-paper.jar /paper/plugins/felis-paper.jar +# The entrypoint writes the Velocity modern-forwarding config (and REFUSES to start +# without the secret — an offline-mode lobby would trust forged identities) before +# launching Paper. +COPY deploy/lobby/entrypoint.sh /usr/local/bin/felis-entrypoint.sh +# The operator mounts the world PVC at /data. Runtime state lives there; /paper +# remains the immutable image seed copied into the volume by the entrypoint. +WORKDIR /data + +# FELIS_GAME_PORT is the port the entrypoint pins Paper to; it MUST equal the operator's +# GamePort (internal/operator/builders.go). Default 25565 — override only in lockstep +# with the operator. +ENV FELIS_GAME_PORT=25565 EXPOSE 25565 -# nogui headless; the first boot generates server.properties (align online-mode / -# forwarding with the Velocity proxy afterwards — see README). -ENTRYPOINT ["java", "-jar", "paper.jar", "--nogui"] +# felis-entrypoint.sh writes config/paper-global.yml + server.properties, then execs +# `java -jar paper.jar --nogui` from /data (headless: the pod has no console). Invoked via +# `sh` so no +x bit is needed from the (Windows) build host. +ENTRYPOINT ["/bin/sh", "/usr/local/bin/felis-entrypoint.sh"] diff --git a/deploy/lobby/entrypoint.sh b/deploy/lobby/entrypoint.sh new file mode 100644 index 0000000..acd3f1b --- /dev/null +++ b/deploy/lobby/entrypoint.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# Felis lobby (Paper) entrypoint. +# +# The lobby sits BEHIND the login gate: a player only reaches it once the limbo has +# authenticated them and Velocity transferred them onward. For that transfer to arrive +# with a real identity, Paper has to be told to verify the proxy's signed handshake — +# otherwise it derives an offline UUID from the username and every /menu action would be +# attributed to whoever typed the name. So, exactly as in deploy/limbo/entrypoint.sh: +# NO SECRET, NO START. Refusing to boot is the safe failure; a lobby that came up in +# offline mode would look healthy while trusting forged identities. +# +# Two files carry the settings: +# +# config/paper-global.yml proxies.velocity.{enabled,online-mode,secret} — enable modern +# forwarding and share the proxy's HMAC key. online-mode mirrors +# the proxy's own online-mode (true: Velocity did the Mojang +# auth), which is what makes the forwarded UUID trustworthy. +# +# server.properties online-mode=false — the PROXY authenticated the player, so the +# backend must not try to reach Mojang itself (Paper refuses to +# start with velocity forwarding on and online-mode=true). This +# is not a downgrade: the trust comes from the signed handshake. +# server-port is pinned to the operator's GamePort (25565), the +# single const the Service, probes and NetworkPolicy all key off. +set -eu + +PORT="${FELIS_GAME_PORT:-25565}" +SECRET="${FELIS_FORWARDING_SECRET:-}" +RUNTIME_DIR="/paper" +DATA_DIR="/data" +PROPS="server.properties" + +if [ -z "$SECRET" ]; then + echo "felis-lobby: FATAL — FELIS_FORWARDING_SECRET is empty." >&2 + echo " Without Velocity modern forwarding Paper cannot verify who a joining player is," >&2 + echo " and would trust an offline UUID derived from the username alone." >&2 + echo " Provision the secret with deploy/bootstrap.sh, then re-run 'sudo felis setup'." >&2 + exit 1 +fi + +# Keep worlds, generated config, and plugin data on the operator-mounted PVC while +# refreshing executable artifacts from the immutable image on every boot. +mkdir -p "$DATA_DIR/plugins" +cp -f "$RUNTIME_DIR/paper.jar" "$DATA_DIR/paper.jar" +cp -f "$RUNTIME_DIR/plugins/felis-paper.jar" "$DATA_DIR/plugins/felis-paper.jar" +printf 'eula=true\n' > "$DATA_DIR/eula.txt" +cd "$DATA_DIR" + +# set_prop KEY VALUE — replace the key's line in server.properties, or append it if absent. +set_prop() { + if [ -f "$PROPS" ] && grep -q "^$1=" "$PROPS"; then + sed -i "s|^$1=.*|$1=$2|" "$PROPS" + else + printf '%s=%s\n' "$1" "$2" >> "$PROPS" + fi +} + +set_prop server-port "$PORT" +set_prop online-mode false + +# ponytail: rewritten whole, not merged. Paper loads this file and fills every key it does +# not find with the default, then writes the full tree back — so a proxies-only file is a +# complete, stable input, and the lobby's other globals are simply always the defaults. +# That is true of a system server Felis owns end to end; if admins are ever allowed to tune +# the lobby's globals, this has to become a real YAML merge (yq) instead. +mkdir -p config +cat > config/paper-global.yml < transferToLobby(id)); } } catch (LinkException e) {