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

This commit is contained in:
Lemon-miaow committed 2026-09-24 16:23:49 +08:00
1 parent c1796bea17
commit 346a93921e
25 files changed
+555 -91

No files matched your search

+4 -4
View File
@@ -113,10 +113,10 @@ type Config struct {
CPULimit string
MemLimit string
// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. They default
// to ROOT (0:0): the world volume is written by the game image's own UID (root
// for the images we ship), and Paper saves mode-0600 files a non-root editor
// can neither read nor rewrite (level.dat). DAC_OVERRIDE on the container
// covers images whose UID is neither root nor ours; FSGroup is omitted when
// to ROOT (0:0): the world volume belongs to the game uid (naming.GameUID), and
// Paper saves mode-0600 files a different non-root uid can neither read nor
// rewrite (level.dat). DAC_OVERRIDE on the container reaches them, CHOWN on a
// write hands the created file back to the game uid; FSGroup is omitted when
// zero.
RunAsUser int64
RunAsGroup int64
+15
View File
@@ -10,6 +10,8 @@ import (
"os"
"path"
"time"
"felis.lolicon.best/internal/naming"
)
// The three operations the editor supports. The set is deliberately closed and
@@ -343,9 +345,22 @@ func write(r *os.Root, path string, content []byte) Result {
if err := f.Close(); err != nil {
return failure(err, path)
}
// The Job runs as root, so a file it just created is root's. The server runs as
// the game uid and could read it (0644) but never rewrite it — a config the
// panel authored that Paper then fails to save. Best effort: the content has
// landed and reporting failure would lie, and the server's prepare-data
// initContainer re-owns anything left behind on its next start anyway.
_ = ownWritten(r, path)
return Result{}
}
// ownWritten hands a written file to the game uid. os.Root.Chown follows a symlink
// only within the root, so this can never re-own a file outside the mount. A var so
// tests, which cannot chown, can observe the call.
var ownWritten = func(r *os.Root, name string) error {
return r.Chown(name, int(naming.GameUID), int(naming.GameGID))
}
// failure maps a filesystem error onto a caller-facing Result code. Anything that
// is genuinely "nothing is there" becomes not_found; EVERYTHING else — including
// every os.Root containment refusal — becomes bad_path.
+10
View File
@@ -194,9 +194,19 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("write creates a new file but not parent directories", func(t *testing.T) {
var owned []string
prev := ownWritten
ownWritten = func(_ *os.Root, name string) error {
owned = append(owned, name)
return os.ErrPermission // a test runner cannot chown; the write must still succeed
}
defer func() { ownWritten = prev }()
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]")); err != nil || res.Code != "" {
t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
}
if len(owned) != 1 || owned[0] != "ops.json" {
t.Errorf("written file handed to the game uid = %v, want [ops.json]", owned)
}
res, err := Execute(root, OpWrite, "nope/deep.txt", []byte("x"))
if err != nil {
t.Fatalf("Execute: %v", err)
+16 -7
View File
@@ -169,12 +169,9 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
Privileged: boolPtr(false),
AllowPrivilegeEscalation: boolPtr(false),
ReadOnlyRootFilesystem: boolPtr(true),
// Root + DAC_OVERRIDE (see Config.RunAsUser): the file the editor is
// asked to touch may be a mode-0600 file the game wrote as its own
// (image) UID — level.dat — which a fixed non-root uid cannot open.
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{"ALL"},
Add: []corev1.Capability{"DAC_OVERRIDE"},
Add: filesCapabilities(p.Op),
},
},
}
@@ -253,9 +250,21 @@ func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i }
// filesPodSecurityContext pins the Pod identity. Root by default: the world
// volume belongs to the game image's UID (root for the images we ship) and its
// mode-0600 files (level.dat) are otherwise unreadable/unwritable. FSGroup is
// filesCapabilities is what the root executor keeps after dropping ALL (see
// Config.RunAsUser). DAC_OVERRIDE opens a mode-0600 file (level.dat) the game wrote
// as its own uid, which a fixed non-root uid could not. A write also keeps CHOWN so
// the file it creates can be handed to naming.GameUID (exec.go ownWritten); a list
// or read changes nothing and gets no more than it needs.
func filesCapabilities(op string) []corev1.Capability {
if op == OpWrite {
return []corev1.Capability{"CHOWN", "DAC_OVERRIDE"}
}
return []corev1.Capability{"DAC_OVERRIDE"}
}
// filesPodSecurityContext pins the Pod identity. Root by default: the world volume
// belongs to the game uid (naming.GameUID), and root with DAC_OVERRIDE reaches its
// mode-0600 files as well as any a previous root-run release left behind. FSGroup is
// only rendered when configured so a root executor never chgrps the volume.
func filesPodSecurityContext(p JobParams) *corev1.PodSecurityContext {
sc := &corev1.PodSecurityContext{
+15 -2
View File
@@ -69,8 +69,8 @@ func TestFilesJobIsolation(t *testing.T) {
if sc == nil || sc.RunAsNonRoot == nil || *sc.RunAsNonRoot {
t.Fatal("RunAsNonRoot must be false: root is the owner-matching default for game-image worlds")
}
// Root because the world volume belongs to the game image's UID and Paper
// saves mode-0600 files a fixed non-root editor cannot open.
// Root because the world volume belongs to the game uid and Paper saves
// mode-0600 files a different non-root editor uid cannot open.
if sc.RunAsUser == nil || *sc.RunAsUser != 0 ||
sc.RunAsGroup == nil || *sc.RunAsGroup != 0 {
t.Fatalf("uid/gid must be 0:0 by default, got %+v", sc)
@@ -105,6 +105,19 @@ func TestFilesJobIsolation(t *testing.T) {
}
})
// Only a write creates a file it must hand back to the game uid, so only a
// write keeps CHOWN; a read stays at DAC_OVERRIDE alone (asserted above).
t.Run("a write also keeps CHOWN", func(t *testing.T) {
w, err := FilesJob(testParams(OpWrite))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
add := w.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add
if len(add) != 2 || add[0] != "CHOWN" || add[1] != "DAC_OVERRIDE" {
t.Fatalf("write capabilities = %v, want [CHOWN DAC_OVERRIDE]", add)
}
})
t.Run("is one-shot, deadlined, and self-collecting", func(t *testing.T) {
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 {
t.Fatal("BackoffLimit must be 0 — a retried write is a second write")