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.
202 lines
7.7 KiB
Go
202 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// forwardingSecretEnv is the env var the operator injects the Velocity
|
|
// modern-forwarding secret under (mirrors internal/operator.envForwardingSecret).
|
|
const forwardingSecretEnv = "FELIS_FORWARDING_SECRET"
|
|
|
|
// defaultForwardingDataDir is the world PVC mount inside a server pod (mirrors
|
|
// internal/operator.dataMountPath). It is Paper's working directory, so its
|
|
// config/ and server.properties live under it.
|
|
const defaultForwardingDataDir = "/data"
|
|
|
|
// fwd*Mode make the written config readable AND rewritable by the main server
|
|
// container, whose UID we do not control (an arbitrary user image). The
|
|
// initContainer runs as root (see buildStatefulSet) so it can write into a data
|
|
// volume of unknown ownership; 0666/0777 then let a non-root Paper rewrite the
|
|
// same files on boot.
|
|
//
|
|
// ponytail: relies on the initContainer running as root to write into a volume of
|
|
// unknown ownership; that is how the operator schedules it. If that ever changes,
|
|
// give the server pod an fsGroup so the shared volume is group-writable instead.
|
|
const (
|
|
fwdFileMode os.FileMode = 0o666
|
|
fwdDirMode os.FileMode = 0o777
|
|
)
|
|
|
|
// cmdInitForwarding is the felis-image initContainer entrypoint that makes an
|
|
// ARBITRARY Paper image joinable behind a modern-forwarding Velocity proxy,
|
|
// WITHOUT modifying that image: it writes the Velocity block into
|
|
// <data>/config/paper-global.yml and forces online-mode=false in
|
|
// <data>/server.properties before the server container starts. This is the same
|
|
// config deploy/lobby/entrypoint.sh writes for the Felis-built lobby, lifted into
|
|
// Go so it can be applied to an image Felis did not build.
|
|
//
|
|
// It is idempotent and MERGE-based: Paper expands paper-global.yml to its full
|
|
// default tree on first boot, and the panel file editor may change either file
|
|
// between boots, so it only ever sets proxies.velocity.* and the single
|
|
// online-mode key and preserves every other setting.
|
|
//
|
|
// "Preserves" means values, not formatting, and only for the YAML half:
|
|
// sigs.k8s.io/yaml round-trips through JSON, so paper-global.yml comes back with
|
|
// its keys sorted and its comments dropped. Every setting survives and Paper reads
|
|
// it back identically, but a user who annotated that file loses the annotations.
|
|
// Accepted rather than fixed: comment-faithful editing means a yaml.v3 Node walk,
|
|
// which is a lot of machinery for two keys. server.properties is edited line-wise
|
|
// and does keep its comments and ordering.
|
|
//
|
|
// An empty/unset secret is a deliberate no-op (exit 0): a cluster whose proxy is
|
|
// not in modern mode provisions no Secret, and wedging every server's init on a
|
|
// missing optional value would be worse than the pre-forwarding status quo.
|
|
func cmdInitForwarding(args []string, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("init-forwarding", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
dataDir := fs.String("data", defaultForwardingDataDir, "server data directory (Paper working dir)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
secret := os.Getenv(forwardingSecretEnv)
|
|
if err := writePaperForwarding(*dataDir, secret); err != nil {
|
|
fmt.Fprintf(stderr, "felis init-forwarding: %v\n", err)
|
|
return 1
|
|
}
|
|
if secret == "" {
|
|
fmt.Fprintln(stdout, "felis init-forwarding: no forwarding secret set; leaving config untouched")
|
|
} else {
|
|
fmt.Fprintln(stdout, "felis init-forwarding: wrote Velocity modern-forwarding config")
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// writePaperForwarding writes both config surfaces (or nothing, when secret == "").
|
|
func writePaperForwarding(dataDir, secret string) error {
|
|
if secret == "" {
|
|
return nil
|
|
}
|
|
if err := writePaperGlobal(dataDir, secret); err != nil {
|
|
return err
|
|
}
|
|
return forceServerPropertyOffline(dataDir)
|
|
}
|
|
|
|
// writePaperGlobal merges the proxies.velocity block into config/paper-global.yml,
|
|
// creating the file and its directory when absent and preserving every other key.
|
|
func writePaperGlobal(dataDir, secret string) error {
|
|
dir := filepath.Join(dataDir, "config")
|
|
if err := os.MkdirAll(dir, fwdDirMode); err != nil {
|
|
return fmt.Errorf("create %s: %w", dir, err)
|
|
}
|
|
// MkdirAll honours the process umask (root's is typically 022 → 0755); chmod
|
|
// does not, and a non-root main container must be able to place/replace the
|
|
// file in this directory on boot.
|
|
if err := os.Chmod(dir, fwdDirMode); err != nil {
|
|
return fmt.Errorf("chmod %s: %w", dir, err)
|
|
}
|
|
|
|
path := filepath.Join(dir, "paper-global.yml")
|
|
root := map[string]any{}
|
|
if existing, err := os.ReadFile(path); err == nil {
|
|
if err := yaml.Unmarshal(existing, &root); err != nil {
|
|
return fmt.Errorf("parse existing %s: %w", path, err)
|
|
}
|
|
if root == nil { // an empty or "null" document unmarshals to a nil map
|
|
root = map[string]any{}
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
setVelocity(root, secret)
|
|
out, err := yaml.Marshal(root)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal %s: %w", path, err)
|
|
}
|
|
return writeFileMode(path, out)
|
|
}
|
|
|
|
// setVelocity sets proxies.velocity.{enabled,online-mode,secret}, creating the
|
|
// intermediate maps when missing. proxies.velocity.online-mode is Paper trusting
|
|
// that the proxy verified the player as premium — distinct from server.properties
|
|
// online-mode, which must be false so the backend does not re-authenticate.
|
|
func setVelocity(root map[string]any, secret string) {
|
|
velocity := childMap(childMap(root, "proxies"), "velocity")
|
|
velocity["enabled"] = true
|
|
velocity["online-mode"] = true
|
|
velocity["secret"] = secret
|
|
}
|
|
|
|
// childMap returns parent[key] as a map, replacing a missing or non-map value with
|
|
// a fresh one. sigs.k8s.io/yaml decodes nested objects to map[string]any (JSON
|
|
// semantics), so the assertion holds for any well-formed paper-global.yml.
|
|
func childMap(parent map[string]any, key string) map[string]any {
|
|
if m, ok := parent[key].(map[string]any); ok {
|
|
return m
|
|
}
|
|
m := map[string]any{}
|
|
parent[key] = m
|
|
return m
|
|
}
|
|
|
|
// forceServerPropertyOffline sets online-mode=false in server.properties. A backend
|
|
// behind a modern-forwarding proxy must be offline-mode (the proxy did the Mojang
|
|
// auth); an arbitrary image defaulting to online-mode=true rejects every proxied
|
|
// login.
|
|
func forceServerPropertyOffline(dataDir string) error {
|
|
path := filepath.Join(dataDir, "server.properties")
|
|
var content []byte
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
content = b
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
return writeFileMode(path, upsertProperty(content, "online-mode", "false"))
|
|
}
|
|
|
|
// upsertProperty sets key=value in a java .properties body, replacing an existing
|
|
// uncommented assignment or appending one, and leaving every other line —
|
|
// comments included — untouched. Keys sit at column 0 the way Paper writes them,
|
|
// so a "#key=" comment does not match.
|
|
func upsertProperty(content []byte, key, value string) []byte {
|
|
want := key + "=" + value
|
|
prefix := key + "="
|
|
lines := strings.Split(string(content), "\n")
|
|
found := false
|
|
for i, ln := range lines {
|
|
if strings.HasPrefix(ln, prefix) {
|
|
lines[i] = want
|
|
found = true
|
|
}
|
|
}
|
|
if found {
|
|
return []byte(strings.Join(lines, "\n"))
|
|
}
|
|
body := string(content)
|
|
if body != "" && !strings.HasSuffix(body, "\n") {
|
|
body += "\n"
|
|
}
|
|
return []byte(body + want + "\n")
|
|
}
|
|
|
|
// writeFileMode writes data then forces the mode, since WriteFile honours the
|
|
// umask (root's is typically 022 → 0644) but a non-root main container must be
|
|
// able to rewrite these files on boot.
|
|
func writeFileMode(path string, data []byte) error {
|
|
if err := os.WriteFile(path, data, fwdFileMode); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
if err := os.Chmod(path, fwdFileMode); err != nil {
|
|
return fmt.Errorf("chmod %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|