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.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:20:07 +08:00
1 parent f21aef3cfa
commit 2010961d32
12 files changed
+223 -105

No files matched your search

+37 -5
View File
@@ -557,7 +557,7 @@ func reaperCronJob(p Params) *batchv1.CronJob {
{Name: tmpVolume, MountPath: "/tmp"},
},
Resources: controlPlaneResources(),
SecurityContext: hardenedContainerSecurityContext(),
SecurityContext: reaperContainerSecurityContext(),
}
volumes := []corev1.Volume{
@@ -609,7 +609,7 @@ func reaperCronJob(p Params) *batchv1.CronJob {
ServiceAccountName: SAReaper,
PriorityClassName: controlPlanePriorityName,
RestartPolicy: corev1.RestartPolicyNever,
SecurityContext: hardenedPodSecurityContext(),
SecurityContext: reaperPodSecurityContext(),
Containers: []corev1.Container{container},
Volumes: volumes,
},
@@ -842,9 +842,11 @@ func controlPlaneResources() corev1.ResourceRequirements {
}
}
// hardenedPodSecurityContext is the pod-level hardening shared by every workload
// here: run as a fixed non-root uid/gid with a matching fsGroup (so the registry
// can write its group-owned PVC) and the RuntimeDefault seccomp profile.
// hardenedPodSecurityContext is the pod-level hardening shared by the
// control-plane workloads (api, operator, registry — the world-touching reaper
// uses reaperPodSecurityContext instead): run as a fixed non-root uid/gid with a
// matching fsGroup (so the registry can write its group-owned PVC) and the
// RuntimeDefault seccomp profile.
//
// SHAPE-ASSERTED, runtime-unverified: this asserts the images can run as
// nonRootUID. The felis image is built to; registry:2 (CNCF Distribution) can,
@@ -860,6 +862,36 @@ func hardenedPodSecurityContext() *corev1.PodSecurityContext {
}
}
// reaperPodSecurityContext is the reaper's Pod identity: ROOT, deliberately NOT
// the control-plane's non-root uid. Its HostPath mount IS the live storage root,
// and the world directories beneath it (and the files inside them) are written
// by the game image's own UID — root for every Paper image we ship — with
// Paper's mode-0600 saves (level.dat) included. Only an owner-matching uid (or
// DAC override, granted on the container below) can archive and delete those
// worlds; the uid-1000 convention failed them with `permission denied`
// (verified live). Same rationale as the operator's forwarding-init container.
func reaperPodSecurityContext() *corev1.PodSecurityContext {
return &corev1.PodSecurityContext{
RunAsNonRoot: boolPtr(false),
RunAsUser: int64Ptr(0),
RunAsGroup: int64Ptr(0),
SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault},
}
}
// reaperContainerSecurityContext is hardenedContainerSecurityContext plus
// DAC_OVERRIDE: with root's caps dropped, root can read only files it owns, and
// a world may have been written by a game image whose UID is neither root nor
// ours. DAC_OVERRIDE restores exactly the file-mode bypass the archive needs.
func reaperContainerSecurityContext() *corev1.SecurityContext {
sc := hardenedContainerSecurityContext()
sc.Capabilities = &corev1.Capabilities{
Drop: []corev1.Capability{"ALL"},
Add: []corev1.Capability{"DAC_OVERRIDE"},
}
return sc
}
// hardenedContainerSecurityContext mirrors the build/restore Job containers: no
// privilege, no escalation, read-only root filesystem (all writes go to the
// mounted volumes — the config/data mounts and the /tmp emptyDir), drop ALL
+12 -3
View File
@@ -704,9 +704,15 @@ func TestReaperCronJob_Shape(t *testing.T) {
t.Errorf("reaper pod must auto-mount its SA token (got AutomountServiceAccountToken=%v); it needs the API", *ps.AutomountServiceAccountToken)
}
// Hardening mirrors the other control-plane pods.
if ps.SecurityContext == nil || ps.SecurityContext.RunAsNonRoot == nil || !*ps.SecurityContext.RunAsNonRoot {
t.Error("reaper pod must set runAsNonRoot=true")
// The reaper is the one world-touching workload, so its identity is ROOT, not
// the control-plane's non-root uid: the worlds it archives and deletes are
// written by the game image's own UID (root for the images we ship), including
// Paper's mode-0600 files. DAC_OVERRIDE covers images with another UID.
if ps.SecurityContext == nil || ps.SecurityContext.RunAsNonRoot == nil || *ps.SecurityContext.RunAsNonRoot {
t.Error("reaper pod must NOT require non-root: root is the owner-matching identity for game-image worlds")
}
if ps.SecurityContext == nil || ps.SecurityContext.RunAsUser == nil || *ps.SecurityContext.RunAsUser != 0 {
t.Error("reaper pod must run as uid 0")
}
if c.SecurityContext == nil || c.SecurityContext.ReadOnlyRootFilesystem == nil || !*c.SecurityContext.ReadOnlyRootFilesystem {
t.Error("reaper container must set readOnlyRootFilesystem=true")
@@ -714,6 +720,9 @@ func TestReaperCronJob_Shape(t *testing.T) {
if c.SecurityContext == nil || c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Drop) == 0 || c.SecurityContext.Capabilities.Drop[0] != "ALL" {
t.Error("reaper container must drop ALL capabilities")
}
if c.SecurityContext == nil || c.SecurityContext.Capabilities == nil || len(c.SecurityContext.Capabilities.Add) != 1 || c.SecurityContext.Capabilities.Add[0] != "DAC_OVERRIDE" {
t.Error("reaper container must add exactly DAC_OVERRIDE")
}
// Entrypoint: `/usr/local/bin/felis reaper --config <cfg> --worlds-root /worlds`.
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "reaper"}) {