Unverified Commit 2010961d authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(workloads): world executors run as root so game-image worlds are readable

A live backup drill on test-one failed: 'tar walk: open
/world/world/level.dat: permission denied'. The world volume belongs to
the game image's own UID (root for every Paper image we ship), and Paper
saves level.dat mode 0600 — a fixed uid-1000 executor can neither read
it (backup/reaper archive) nor overwrite it (restore). The same identity
silently broke on-demand backups, restores, and the reaper for every
server that had saved once.

Run the backup Job, restore Job, file Job, and the reaper pod as root
with DAC_OVERRIDE on top of drop-ALL — the same owner-matching precedent
as the operator's forwarding-init container; DAC_OVERRIDE extends it to
game images whose UID is neither root nor ours. FSGroup is omitted when
zero so a root executor never chgrps the world volume. Shape tests
updated for the new identity.
parent f21aef3c
Loading
Loading
Loading
Loading
+11 −5
Changes for docs/troubleshooting.md: 11 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -557,11 +557,17 @@ the flag at k3s's storage root (`/var/lib/rancher/k3s/storage`) is therefore the
supported way to enable retention on a stock install. Two deployment facts the
resolver cannot fix:

- **Permissions.** The reaper pod runs as uid 1000, while k3s creates its
  storage root `0700 root:root`. Without traverse (`setfacl -m u:1000:x`, or
  `chmod o+x`; the installer applies this when `FELIS_WORLDS_HOST_PATH` is set)
  every walk fails `permission denied` / `lstat …: permission denied` and the
  world is **preserved**, never reaped — a silent no-op with ERROR logs.
- **Permissions.** The reaper Pod runs as **root** and carries `DAC_OVERRIDE`:
  worlds are written by the game image's own UID (root for every Paper image we
  ship), and Paper saves `level.dat` mode-0600, so any fixed non-root identity
  (the previous uid-1000 convention, and the ACL setup that went with it) could
  neither walk the tree nor read the files — every archive failed
  `open …/level.dat: permission denied` and the same defect failed on-demand
  backups/restores. Root is the same identity the game container itself runs as
  (see the operator's forwarding-init note); `DAC_OVERRIDE` extends the archive
  to game images with a different UID. If a world is still **preserved** while a
  reap was expected, it is now a different cause: check the run's ERROR logs for
  the resolver's `lstat` messages before suspecting permissions.
- **Node placement.** Multi-node clusters: the world's directory exists only on
  the node holding its volume, and the CronJob sets no `nodeSelector`, so add
  one (single-node starters are pinned implicitly).
+8 −13
Changes for internal/backupjob/backup.go: 8 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -87,9 +87,14 @@ type Config struct {
	// CPULimit / MemLimit cap the backup container.
	CPULimit string
	MemLimit string
	// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. FSGroup MUST
	// match the operator StatefulSet's runtime group so the read-only world mount is
	// readable by this Pod's uid.
	// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. They default
	// to ROOT (0:0) for the same reason the operator's forwarding-init container
	// runs as root: the world volume is written by the game image's own UID (root
	// for every Paper image we ship), and Paper saves files a non-root uid can
	// never read — level.dat is written mode 0600 (tar walk: permission denied,
	// verified live). DAC_OVERRIDE on the container covers images whose UID is
	// neither root nor ours. Set 0/0/0 explicitly for root; FSGroup is omitted
	// when zero.
	RunAsUser  int64
	RunAsGroup int64
	FSGroup    int64
@@ -111,7 +116,6 @@ const (
	defaultDeadline       = 30 * time.Minute
	defaultCPULimit       = "1"
	defaultMemLimit       = "1Gi"
	defaultRunAsID        = int64(1000)
	defaultTTL            = 10 * time.Minute
)

@@ -145,15 +149,6 @@ func (c Config) withDefaults() Config {
	if c.MemLimit == "" {
		c.MemLimit = defaultMemLimit
	}
	if c.RunAsUser == 0 {
		c.RunAsUser = defaultRunAsID
	}
	if c.RunAsGroup == 0 {
		c.RunAsGroup = defaultRunAsID
	}
	if c.FSGroup == 0 {
		c.FSGroup = defaultRunAsID
	}
	if c.TTLAfterFinished <= 0 {
		c.TTLAfterFinished = defaultTTL
	}
+30 −7
Changes for internal/backupjob/jobspec.go: 30 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -148,7 +148,14 @@ func BackupJob(p JobParams) (*batchv1.Job, error) {
			Privileged:               boolPtr(false),
			AllowPrivilegeEscalation: boolPtr(false),
			ReadOnlyRootFilesystem:   boolPtr(true),
			Capabilities:             &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
			// DAC_OVERRIDE is granted on top of dropping ALL: the pod runs as root,
			// but the world may have been written by a game image whose UID is
			// neither root nor ours, and Paper's own files are mode 0600. It is the
			// minimal extra power that makes the archive read every world shape.
			Capabilities: &corev1.Capabilities{
				Drop: []corev1.Capability{"ALL"},
				Add:  []corev1.Capability{"DAC_OVERRIDE"},
			},
		},
	}

@@ -175,12 +182,11 @@ func BackupJob(p JobParams) (*batchv1.Job, error) {
					RestartPolicy:                corev1.RestartPolicyNever,
					ServiceAccountName:           p.ServiceAccount,
					AutomountServiceAccountToken: boolPtr(false),
					SecurityContext: &corev1.PodSecurityContext{
						RunAsNonRoot: boolPtr(true),
						RunAsUser:    int64Ptr(p.RunAsUser),
						RunAsGroup:   int64Ptr(p.RunAsGroup),
						FSGroup:      int64Ptr(p.FSGroup),
					},
					// Root by default (see Config.RunAsUser): the world volume's
					// owner is the game image's UID, so only an owner-matching or
					// DAC-overriding uid can read it. FSGroup is omitted when unset
					// so a root pod never triggers a volume chgrp.
					SecurityContext: backupPodSecurityContext(p),
					Containers:      []corev1.Container{container},
					Volumes: []corev1.Volume{
						{
@@ -240,3 +246,20 @@ func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
func boolPtr(b bool) *bool    { return &b }
func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i }

// backupPodSecurityContext pins the Pod identity. RunAsNonRoot is false because
// the default identity is root: worlds are owned by the game image's UID (root
// for the images we ship), and Paper writes mode-0600 files a non-root reader
// cannot open. FSGroup stays unset unless configured — a root executor must not
// needlessly chgrp the world volume.
func backupPodSecurityContext(p JobParams) *corev1.PodSecurityContext {
	sc := &corev1.PodSecurityContext{
		RunAsNonRoot: boolPtr(false),
		RunAsUser:    int64Ptr(p.RunAsUser),
		RunAsGroup:   int64Ptr(p.RunAsGroup),
	}
	if p.FSGroup > 0 {
		sc.FSGroup = int64Ptr(p.FSGroup)
	}
	return sc
}
+25 −5
Changes for internal/backupjob/jobspec_test.go: 25 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -23,9 +23,9 @@ func sampleJobParams() JobParams {
		Deadline:         30 * time.Minute,
		CPULimit:         "1",
		MemLimit:         "1Gi",
		RunAsUser:        1000,
		RunAsGroup:       1000,
		FSGroup:          1000,
		RunAsUser:        0,
		RunAsGroup:       0,
		FSGroup:          0,
		TTLAfterFinished: 10 * time.Minute,
	}
}
@@ -109,13 +109,30 @@ func TestBackupJobMountsTwoPVCsPlusConfigSecretOnly(t *testing.T) {
	}
}

// The backup container is hardened exactly like the restore/build Job containers:
// no privilege, no escalation, read-only root fs, drop ALL capabilities.
// The backup container is hardened like the restore/build Job containers: no
// privilege, no escalation, read-only root fs, ALL capabilities dropped — plus
// DAC_OVERRIDE, because the Pod runs as root and the world may have been written
// by a game image with a different UID (verified live: a uid-1000 executor cannot
// read Paper's mode-0600 level.dat).
func TestBackupJobContainerIsHardened(t *testing.T) {
	job, err := BackupJob(sampleJobParams())
	if err != nil {
		t.Fatalf("BackupJob: %v", err)
	}
	pod := job.Spec.Template.Spec
	if pod.SecurityContext == nil {
		t.Fatal("pod SecurityContext is nil")
	}
	if pod.SecurityContext.RunAsNonRoot == nil || *pod.SecurityContext.RunAsNonRoot {
		t.Error("pod must NOT require non-root: root is the owner-matching default for game-image worlds")
	}
	if pod.SecurityContext.RunAsUser == nil || *pod.SecurityContext.RunAsUser != 0 ||
		pod.SecurityContext.RunAsGroup == nil || *pod.SecurityContext.RunAsGroup != 0 {
		t.Errorf("pod must run as 0:0 by default, got %+v", pod.SecurityContext)
	}
	if pod.SecurityContext.FSGroup != nil {
		t.Error("fsGroup must stay unset when zero (a root executor must not chgrp the world volume)")
	}
	sc := job.Spec.Template.Spec.Containers[0].SecurityContext
	if sc == nil {
		t.Fatal("container SecurityContext is nil")
@@ -132,6 +149,9 @@ func TestBackupJobContainerIsHardened(t *testing.T) {
	if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" {
		t.Error("capabilities must drop ALL")
	}
	if len(sc.Capabilities.Add) != 1 || sc.Capabilities.Add[0] != "DAC_OVERRIDE" {
		t.Errorf("capabilities must add exactly DAC_OVERRIDE, got %v", sc.Capabilities.Add)
	}
}

// One-shot: a wedged archive must not loop, and a deadline caps it.
+6 −13
Changes for internal/fileedit/editor.go: 6 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -112,9 +112,12 @@ type Config struct {
	// CPULimit / MemLimit cap the container.
	CPULimit string
	MemLimit string
	// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. FSGroup MUST
	// match the operator StatefulSet's runtime group, or a file this Pod writes
	// would be unreadable by the minecraft server that later mounts the same PVC.
	// 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
	// zero.
	RunAsUser  int64
	RunAsGroup int64
	FSGroup    int64
@@ -136,7 +139,6 @@ const (
	defaultTimeout        = 90 * time.Second
	defaultCPULimit       = "500m"
	defaultMemLimit       = "256Mi"
	defaultRunAsID        = int64(1000)
	defaultTTL            = 2 * time.Minute
)

@@ -164,15 +166,6 @@ func (c Config) withDefaults() Config {
	if c.MemLimit == "" {
		c.MemLimit = defaultMemLimit
	}
	if c.RunAsUser == 0 {
		c.RunAsUser = defaultRunAsID
	}
	if c.RunAsGroup == 0 {
		c.RunAsGroup = defaultRunAsID
	}
	if c.FSGroup == 0 {
		c.FSGroup = defaultRunAsID
	}
	if c.TTLAfterFinished <= 0 {
		c.TTLAfterFinished = defaultTTL
	}
Loading