Velocity modern forwarding is proxy-WIDE. A backend that cannot verify the signed handshake does not degrade -- it rejects every login the proxy forwards. Until now the only backends that could verify it were the two images Felis builds itself (deploy/limbo, deploy/lobby), which read FELIS_FORWARDING_SECRET in their own entrypoints. An arbitrary Paper image a user brings does not, so it passed admission, started, reported Ready, and was UNJOINABLE. The platform's answer was to recommend the lobby image as a base for a user's own world (0018_recommended_images.sql), which was never a good base -- it carries the /menu plugin whose job is to TRANSFER a joining player away, the exact opposite of a server you mean to stay on. The fix configures forwarding from OUTSIDE the image instead of requiring it inside. The operator now injects a root `felis init-forwarding` initContainer into every user server; it writes the proxies.velocity block into config/paper-global.yml and forces online-mode=false in server.properties on the /data PVC before the main container starts. The image needs no forwarding logic of its own, so the joinable set stops being "images that self-configure forwarding" and becomes every Paper-family image the platform runs. buildStatefulSet gates the injection on the ABSENCE of the system-role label: the Felis-built system servers already consume the secret in their entrypoints and the login gate is a limbo, not Paper. It is also gated on a non-empty felis image name -- the operator Deployment passes its own image as FELIS_IMAGE, and an operator without it skips the injection rather than failing, because a cluster whose proxy is not in modern mode has nothing to configure. The init runs as root deliberately. The world volume's ownership comes from the storage provisioner and the main container runs as whatever UID its image declares, so root is the only UID that can reliably write these files; it then chmods them 0666/0777 so that non-root main container can rewrite them on boot. The privilege is bounded -- the init exits before the server container starts and the server container keeps its own UID. The alternative, an fsGroup on the pod, is noted in the code as the upgrade path if the init ever stops running as root. The writer merges rather than overwrites, both because Paper expands paper-global.yml to its full default tree on first boot and because the panel file editor may edit either file between boots. It sets proxies.velocity.* and the single online-mode key and leaves every other setting alone. It is a no-op on an empty secret, for the same reason the env var is optional: a proxy that is not in modern mode provisions no Secret, and wedging every server's init on a missing optional value would be worse than the status quo. felis-paper (deploy/paper) is the platform's plain-Paper expression of that base and 0019 seeds it recommended: same PAPER_JAR_URL the lobby build already resolves, no /menu plugin, no forwarding gate, and a correctly-escaped RCON channel so the console, the online-player list and permission commands work out of the box. 0018's row is left in place -- an admin who kept it can keep it; this only adds the better default beside it. Three fixes ride along, each of which the 1.8 path hit in practice. bootstrap pins ViaVersion's serverside-blockconnections off. ConnectionData.init() only builds its block-connection provider when Via's lowest supported protocol is below 1.13; under modern forwarding the Velocity injector reports 393, so init() returns early, blockConnectionProvider stays null, and the first 1.12.2->1.13 chunk rewrite dereferences it -- a 1.8 client takes an NPE on the first chunk it is sent and never finishes joining. Every call site is behind isServersideBlockConnections(), so switching it off skips all of them, at a cosmetic pre-1.13 cost: fences and glass panes stop drawing connected. ViaVersion ships the option ON, so a fresh install shipped that NPE. Seeding a file with this one key suffices -- Config#loadConfig parses the bundled default as the base map and merges the on-disk file over it, so every other option stays current across version bumps. The absence of "Loading block connection mappings" in the log is NOT evidence this worked: init() gates on the protocol version too, and that half fails on its own, so the line is missing either way. The config value is the only evidence, which is what the test asserts. The Velocity unit gains -Dfelis.legacy-forwarding.servers=legacy18. A protocol-47 backend sits behind ViaVersion, which strips modern forwarding's login-plugin-message when it down-translates the proxy->backend pipeline to 47 -- the packet is registered from 1.13 and has nowhere to go. Only the handshake address field survives Via, so the Felis fork forwards the named servers BungeeCord-style while every other backend keeps modern+secret untouched. v1 hardcodes the one legacy backend; rendering the list from the MinecraftServer CRs is the upgrade path. deploy/lobby's set_prop escapes the value before substituting it. The RCON password is operator-provisioned arbitrary bytes, and a '|', '\' or '&' in one corrupts a bare `sed s|...|...|` and silently kills the key -- taking the console, the online-player list and permission commands with it. deploy/paper was written with the escaping, so the lobby gets the same rather than leaving the sibling caller broken. Verified: the full Go suite passes on Windows and on Fedora 44 (go1.26.4), where TestWriteForwardingFileModes actually runs its POSIX mode assertions instead of skipping. The new tests cover the initContainer's image, root UID, world mount and secret env; the merge preserving unrelated config trees; the properties upsert including the commented-key case; and the bootstrap script both writing the Via key and still calling the function that writes it. Not verified: the initContainer has never run in a real cluster, and the felis-paper image is code-only here as the other game-stack images are -- no Go CI builds them. The ViaVersion pin is the one piece with live evidence, and that evidence is what it was written from. Before it, a client was cut within a second of "logged in with entity id" on legacy18 while the proxy logged the NPE above -- REMAP OF LEVEL_CHUNK chained into Protocol1_8To1_9's MAP_BULK_CHUNK. It was applied by hand to the running proxy on 2026-07-24 at 14:47 and only then written back into bootstrap. At 14:48:14 the same player joined real Paper 1.8.8 through the fork, issued commands, approved an op-login from in-game at 14:50:39, and held the connection until 15:30:09 -- 42 minutes. Neither session says which client version it was. The proxy never logged a protocol number. It bounds above at 1.16.4, from the viabackwards "(1.17->1.16.4) ... for 1.16 players and below" warning that fired for that player on the lobby leg, and no lower -- Via floors every handshake to the proxy's 393, so anything from 47 up is admissible. Reading Protocol1_8To1_9 in the stack as a client-version tell is backwards: that chain runs on the BACKEND leg, up-translating the 47 server's chunks to the floor. What the NPE proves is that the pin was load-bearing, not who was holding the mouse. That is one hand-run session on one host, and it is not a cell. The 393->47 leg has one now, in Felis-Legacy -- FL-009 puts a genuine protocol-47 client on a stock Paper 1.8.8 behind this proxy and flips this same option: on it, cut 0.2s after JoinGame with the fault above; off, holds. No automated test in THIS repository exercises the leg.
121 lines
6.1 KiB
Bash
121 lines
6.1 KiB
Bash
#!/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"
|
|
# Only the jar is refreshed — LuckPerms keeps its H2 database and config under
|
|
# $DATA_DIR/plugins/LuckPerms/, which is exactly the state the PVC exists to preserve.
|
|
# Every grant the panel has ever issued lives there, so this must never be a wipe.
|
|
cp -f "$RUNTIME_DIR/plugins/LuckPerms.jar" "$DATA_DIR/plugins/LuckPerms.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
|
|
# The RCON password is operator-provisioned arbitrary bytes: a '|', '\' or '&' would
|
|
# otherwise corrupt this bare sed s||| and silently break the key. Same escaping as
|
|
# deploy/limbo — without it an unlucky password kills the console/permission channel.
|
|
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"
|
|
set_prop online-mode false
|
|
|
|
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
|
|
# for readiness and the player tally, and felis-api runs console/permission commands over
|
|
# it. Paper only reads these three keys from server.properties, so the operator's injected
|
|
# RCON_PASSWORD has to be written here to take effect — env alone does nothing.
|
|
#
|
|
# Rewritten on EVERY boot from the Secret, deliberately. That makes the value in the world
|
|
# volume derived state rather than the source of truth: an owner who edits (or clobbers)
|
|
# these lines through the panel's file editor cannot lock the control plane out of their
|
|
# own server, because the next restart restores the real password. The editor is also kept
|
|
# from reading the password back out — see internal/fileedit/exec.go (spec §286: RCON
|
|
# 密码绝不下发前端).
|
|
#
|
|
# No password, no RCON: an empty enable-rcon=true would let anything that reaches the port
|
|
# in unauthenticated. Unlike the forwarding secret this is not fatal — a server without the
|
|
# write channel still serves players — so it warns and starts rather than refusing.
|
|
if [ -n "${RCON_PASSWORD:-}" ]; then
|
|
set_prop enable-rcon true
|
|
set_prop rcon.port "${RCON_PORT:-25575}"
|
|
set_prop rcon.password "$RCON_PASSWORD"
|
|
echo "felis-lobby: rcon enabled on port ${RCON_PORT:-25575}"
|
|
else
|
|
set_prop enable-rcon false
|
|
echo "felis-lobby: WARNING — RCON_PASSWORD is empty, so the console, the online-player" >&2
|
|
echo " list and permission changes will be unavailable for this server. The operator" >&2
|
|
echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2
|
|
fi
|
|
|
|
# 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 <<YAML
|
|
# Written by felis-lobby's entrypoint on every boot. Do not hand-edit: the forwarding
|
|
# secret is injected from the felis-forwarding-secret Secret and must match the proxy.
|
|
proxies:
|
|
velocity:
|
|
enabled: true
|
|
online-mode: true
|
|
secret: "${SECRET}"
|
|
YAML
|
|
|
|
echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (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 paper.jar --nogui "$@"
|