fix(operator): 游戏 Pod 改以 UID 1000 运行并丢弃全部能力,prepare-data 初始化容器修正旧存档属主
This commit is contained in:
25 files changed
+555
-91
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user