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:
12 files changed
+223
-105
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
Reference in new issue
Block a user