Unverified Commit 346a9392 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(operator): 游戏 Pod 改以 UID 1000 运行并丢弃全部能力,prepare-data 初始化容器修正旧存档属主

parent c1796bea
Loading
Loading
Loading
Loading
+11 −16
Changes for cmd/felis/initforwarding.go: 11 added lines, 16 removed lines.
Original line number Diff line number Diff line
@@ -20,18 +20,14 @@ const forwardingSecretEnv = "FELIS_FORWARDING_SECRET"
// 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.
//
// This 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.
// 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 = 0o666
	fwdDirMode  os.FileMode = 0o777
	fwdFileMode os.FileMode = 0o644
	fwdDirMode  os.FileMode = 0o755
)

// cmdInitForwarding is the felis-image initContainer entrypoint that makes an
@@ -96,9 +92,8 @@ func writePaperGlobal(dataDir, secret string) error {
	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.
	// 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)
	}
@@ -188,8 +183,8 @@ func upsertProperty(content []byte, key, value string) []byte {
}

// 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.
// 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)
+3 −2
Changes for cmd/felis/initforwarding_test.go: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -164,8 +164,9 @@ func TestUpsertPropertyAppends(t *testing.T) {
	}
}

// The written files must be group/world writable so a non-root main container can
// rewrite them. chmod semantics are POSIX-only, so this asserts on non-Windows.
// The written files land at fwdFileMode: owner-writable for the game uid the init
// shares with the server container, and no longer world-writable. chmod semantics
// are POSIX-only, so this asserts on non-Windows.
func TestWriteForwardingFileModes(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX file modes not represented on Windows")
+117 −0
Changes for cmd/felis/initvolume.go: 117 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"os"
	"syscall"

	"felis.lolicon.best/internal/naming"
)

// cmdInitVolume is the felis-image `prepare-data` initContainer entrypoint: it
// hands every entry of a server's world volume to the game uid/gid before the
// server container starts. The operator runs the server itself as naming.GameUID,
// so a world written by an earlier release (whose server ran as root), a restore
// Job (which extracts as root), or a storage provisioner that creates the volume
// root-owned would otherwise leave files the server cannot write — a world that
// boots and then fails every save.
//
// fsGroup covers only part of this: kubelet applies it to volume types that
// support ownership management, and a k3s local-path PV is a hostPath underneath,
// which it skips. A walk from inside the pod works for every volume type.
//
// Only mismatched entries are touched, so a volume already owned by the game uid
// costs one lstat per entry and no writes. The walk runs inside an os.Root at the
// data dir and uses lchown, so a symlink a plugin planted is re-owned as a link
// and never followed out of the volume.
//
// A single entry that cannot be chowned is reported and skipped: failing the pod
// over one odd file would keep the whole server down, while the server itself
// reports the one file it cannot write. Only an unreadable data dir fails.
func cmdInitVolume(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("init-volume", flag.ContinueOnError)
	fs.SetOutput(stderr)
	dataDir := fs.String("data", defaultForwardingDataDir, "world volume mount to hand to the game uid")
	uid := fs.Int64("uid", naming.GameUID, "owner uid for every entry")
	gid := fs.Int64("gid", naming.GameGID, "owner gid for every entry")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	root, err := os.OpenRoot(*dataDir)
	if err != nil {
		fmt.Fprintf(stderr, "felis init-volume: open %s: %v\n", *dataDir, err)
		return 1
	}
	defer root.Close()
	res, err := chownTree(root, int(*uid), int(*gid), root.Lchown)
	if err != nil {
		fmt.Fprintf(stderr, "felis init-volume: %v\n", err)
		return 1
	}
	for _, f := range res.failures {
		fmt.Fprintf(stderr, "felis init-volume: %s\n", f)
	}
	fmt.Fprintf(stdout, "felis init-volume: %d entries checked, %d handed to %d:%d, %d failed\n",
		res.checked, res.changed, *uid, *gid, len(res.failures))
	return 0
}

// chownResult tallies one walk; failures is capped so a volume of thousands of
// unownable files cannot flood the pod log.
type chownResult struct {
	checked  int
	changed  int
	failures []string
}

const maxReportedChownFailures = 20

// chownTree walks root and calls chown on every entry (the root dir included)
// whose owner is not uid:gid. It returns an error only when the root itself
// cannot be read; per-entry failures are collected in the result.
func chownTree(root *os.Root, uid, gid int, chown func(name string, uid, gid int) error) (chownResult, error) {
	var res chownResult
	fail := func(name string, err error) {
		if len(res.failures) < maxReportedChownFailures {
			res.failures = append(res.failures, fmt.Sprintf("%s: %v", name, err))
		} else if len(res.failures) == maxReportedChownFailures {
			res.failures = append(res.failures, "further failures not listed")
		}
	}
	err := fs.WalkDir(root.FS(), ".", func(name string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			if name == "." {
				return walkErr
			}
			fail(name, walkErr)
			// A directory that cannot be listed is skipped as a whole; a file
			// error has nothing below it to skip.
			if d != nil && d.IsDir() {
				return fs.SkipDir
			}
			return nil
		}
		res.checked++
		info, err := d.Info()
		if err != nil {
			fail(name, err)
			return nil
		}
		if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) == uid && int(st.Gid) == gid {
			return nil
		}
		if err := chown(name, uid, gid); err != nil {
			if !errors.Is(err, fs.ErrNotExist) { // gone mid-walk: nothing left to own
				fail(name, err)
			}
			return nil
		}
		res.changed++
		return nil
	})
	return res, err
}
+124 −0
Changes for cmd/felis/initvolume_test.go: 124 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"bytes"
	"os"
	"path/filepath"
	"runtime"
	"slices"
	"strconv"
	"testing"
)

// openTree builds a small world under a temp dir: nested dirs, a file, and a
// symlink pointing out of the volume that the walk must not follow.
func openTree(t *testing.T) *os.Root {
	t.Helper()
	dir := t.TempDir()
	for _, d := range []string{"world/region", "plugins"} {
		if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
			t.Fatal(err)
		}
	}
	for _, f := range []string{"level.dat", "world/region/r.0.0.mca"} {
		if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	outside := t.TempDir()
	if err := os.Symlink(outside, filepath.Join(dir, "plugins", "escape")); err != nil {
		t.Fatal(err)
	}
	root, err := os.OpenRoot(dir)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { root.Close() })
	return root
}

// Every entry owned by someone else is handed over, the root dir included, and a
// symlink is re-owned as a link rather than walked into.
func TestChownTreeHandsOverMismatchedEntries(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX ownership not represented on Windows")
	}
	root := openTree(t)
	var got []string
	res, err := chownTree(root, os.Getuid()+1, os.Getgid(), func(name string, uid, gid int) error {
		got = append(got, name)
		return nil
	})
	if err != nil {
		t.Fatalf("chownTree: %v", err)
	}
	want := []string{".", "level.dat", "plugins", "plugins/escape", "world", "world/region", "world/region/r.0.0.mca"}
	slices.Sort(got)
	if !slices.Equal(got, want) {
		t.Errorf("chowned %v, want %v", got, want)
	}
	if res.changed != len(want) || res.checked != len(want) || len(res.failures) != 0 {
		t.Errorf("result = %+v, want %d checked and changed, no failures", res, len(want))
	}
}

// A volume already owned by the game uid costs no chown at all: this is the steady
// state every restart after the first one hits.
func TestChownTreeSkipsMatchingOwner(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX ownership not represented on Windows")
	}
	root := openTree(t)
	calls := 0
	res, err := chownTree(root, os.Getuid(), os.Getgid(), func(string, int, int) error {
		calls++
		return nil
	})
	if err != nil {
		t.Fatalf("chownTree: %v", err)
	}
	if calls != 0 || res.changed != 0 {
		t.Errorf("chown called %d times on an already-owned tree (result %+v)", calls, res)
	}
}

// One entry that refuses the chown is reported and the walk carries on: a single
// odd file must not keep the whole server from starting.
func TestChownTreeContinuesPastFailures(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX ownership not represented on Windows")
	}
	root := openTree(t)
	res, err := chownTree(root, os.Getuid()+1, os.Getgid(), func(name string, uid, gid int) error {
		if name == "level.dat" {
			return os.ErrPermission
		}
		return nil
	})
	if err != nil {
		t.Fatalf("chownTree: %v", err)
	}
	if len(res.failures) != 1 || res.changed != 6 {
		t.Errorf("result = %+v, want 1 failure and 6 changed", res)
	}
}

// Against a real directory the owner already matches, so the command succeeds
// without needing CAP_CHOWN — the path every test runner (non-root) can take.
func TestCmdInitVolumeOwnedTree(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX ownership not represented on Windows")
	}
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "server.properties"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	var out, errb bytes.Buffer
	code := cmdInitVolume([]string{"--data", dir, "--uid", strconv.Itoa(os.Getuid()), "--gid", strconv.Itoa(os.Getgid())}, &out, &errb)
	if code != 0 {
		t.Fatalf("exit %d, stderr %q", code, errb.String())
	}
	if code := cmdInitVolume([]string{"--data", filepath.Join(dir, "missing")}, &out, &errb); code != 1 {
		t.Errorf("missing data dir exit = %d, want 1", code)
	}
}
+1 −0
Changes for cmd/felis/run.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -63,6 +63,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
	"breakGlass":       cmdBreakGlass,
	"bootstrap-assets": cmdBootstrapAssets,
	"init-forwarding":  cmdInitForwarding,
	"init-volume":      cmdInitVolume,
	"version":          cmdVersion,
	"update":           cmdUpdate,
}
Loading