Files
Felis/cmd/felis/initforwarding.go
T

197 lines
7.5 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 are the modes the written config lands with. The initContainer runs as
// the same uid as the server container (naming.GameUID, pinned by the operator in
// the pod securityContext) after the prepare-data initContainer has handed the
// whole volume to that uid, so owner read/write is all the server needs to rewrite
// these files on boot and nothing else on the node gets write access to them.
const (
fwdFileMode os.FileMode = 0o644
fwdDirMode os.FileMode = 0o755
)
// 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; chmod does not, so a directory an older
// release left at 0777 is brought back to fwdDirMode here.
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 and leaves an existing file's mode alone: a file an older release wrote
// world-writable (0666) is tightened back to fwdFileMode on the next 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
}