From f44968e6c62a4ee2bca9769b3fc3f8302e099e55 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:37:11 +0100 Subject: [PATCH] Isolate rootless qualification cgroups --- .../v6/internal/subsystems/agent-lifecycle.md | 6 ++++- .../subsystems/deployment-installability.md | 5 +++- ...ure_runtime_rootless_qualification_test.go | 25 ++++++++++++++++--- ...n-secure-runtime-rootless-qualification.sh | 4 +-- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 879bf7992..61b03fdef 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -7313,7 +7313,11 @@ must pre-create their root-owned, mode-`0700` destination before the wrapper injects the exact packet into a stopped disposable container. The wrapper uses the supported `slirp4netns` rootless Docker driver only inside that outer `--network none` boundary; selecting the unsupported `host` RootlessKit driver -or granting an outer default route is invalid qualification. The wrapper +or granting an outer default route is invalid qualification. The outer host +uses a private cgroup namespace with no host cgroup bind mount. Each dedicated +runtime identity must have an active delegated systemd user manager before its +daemon starts, and cleanup must stop that manager and remove its linger state. +The wrapper exercises each runtime in an isolated state root and emits the standalone `secure-runtime-rootless-v1` receipt only after exact socket ownership, daemon rootless attestation, installer pinning, direct telemetry, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a9cc8f385..c408d0781 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -264,7 +264,10 @@ must pre-create its root-owned, mode-`0700` packet destination before the wrapper injects artifacts into each stopped disposable container. Rootless Docker uses its supported `slirp4netns` driver inside the outer `--network none` container only; an unsupported `host` RootlessKit driver or -an outer default route invalidates the proof. +an outer default route invalidates the proof. The disposable host uses a +private cgroup namespace without a host cgroup bind mount, creates an active +delegated systemd user manager for the dedicated runtime identity, and removes +that manager's linger state during cleanup. Its standalone `secure-runtime-rootless-v1` receipt must bind the exact qualification, collector, helper, installer, source-manifest, socket, fresh-install, legacy-migration, restart, fallback, recovery, ambiguity, diff --git a/scripts/installtests/secure_runtime_rootless_qualification_test.go b/scripts/installtests/secure_runtime_rootless_qualification_test.go index decdb1ad3..5a3c6c788 100644 --- a/scripts/installtests/secure_runtime_rootless_qualification_test.go +++ b/scripts/installtests/secure_runtime_rootless_qualification_test.go @@ -160,7 +160,7 @@ func TestSecureRuntimeRootlessQualification(t *testing.T) { defer server.Close() daemon := rootlessQualPrepareDaemons(t, runtimeKind) - defer rootlessQualBestEffortStop(daemon.rootlessUnit, daemon.rootfulUnit, rootlessQualOtherUnit(runtimeKind)) + defer rootlessQualBestEffortStop(daemon.rootlessUnit, daemon.rootfulUnit, rootlessQualOtherUnit(runtimeKind), fmt.Sprintf("user@%d.service", daemon.uid)) rootlessQualStartRootful(t, daemon) rootlessQualStartRootless(t, daemon) identity := rootlessQualReadIdentityRecord(t, daemon) @@ -414,6 +414,7 @@ func TestSecureRuntimeRootlessQualification(t *testing.T) { rootlessQualUninstallPulse(t, installerPath, server.URL) rootlessQualStopUnit(t, daemon.rootlessUnit) rootlessQualStopUnit(t, daemon.rootfulUnit) + rootlessQualStopUserManager(t, daemon) rootlessQualRemoveRuntimeState(t, daemon) rootlessQualAssertPulseRemoved(t) for _, socket := range []string{daemon.rootlessSock, daemon.rootfulSock} { @@ -539,6 +540,16 @@ func rootlessQualPrepareDaemons(t *testing.T, runtimeKind string) rootlessQualDa for _, path := range paths { rootlessQualCommand(t, 10*time.Second, "install", "-d", "-o", "pulse-agent", "-g", "pulse-agent", "-m", "0700", path) } + rootlessQualCommand(t, 10*time.Second, "loginctl", "enable-linger", "pulse-agent") + userUnit := fmt.Sprintf("user@%d.service", uid) + rootlessQualCommand(t, 20*time.Second, "systemctl", "start", userUnit) + active := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=ActiveState", "--value") + delegated := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=Delegate", "--value") + controlGroup := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=ControlGroup", "--value") + expectedControlGroup := fmt.Sprintf("/user.slice/user-%d.slice/user@%d.service", uid, uid) + if active != "active" || delegated != "yes" || controlGroup != expectedControlGroup { + t.Fatalf("rootless user manager is not exactly delegated: active=%q delegate=%q control_group=%q", active, delegated, controlGroup) + } containerfile := "FROM scratch\nCOPY busybox /busybox\nENTRYPOINT [\"/busybox\"]\n" if err := os.WriteFile(filepath.Join(home, "fixture", "Containerfile"), []byte(containerfile), 0o600); err != nil { t.Fatal(err) @@ -1030,8 +1041,14 @@ func rootlessQualRemoveRuntimeState(t *testing.T, d rootlessQualDaemon) { } } +func rootlessQualStopUserManager(t *testing.T, d rootlessQualDaemon) { + t.Helper() + rootlessQualCommand(t, 10*time.Second, "loginctl", "disable-linger", "pulse-agent") + rootlessQualStopUnit(t, fmt.Sprintf("user@%d.service", d.uid)) +} + func rootlessQualUserStateClean(d rootlessQualDaemon) bool { - for _, path := range []string{d.home, filepath.Join("/run/user", strconv.Itoa(d.uid)), "/var/lib/pulse-rootful-docker", "/var/lib/containers/storage"} { + for _, path := range []string{d.home, filepath.Join("/run/user", strconv.Itoa(d.uid)), "/var/lib/pulse-rootful-docker", "/var/lib/containers/storage", "/var/lib/systemd/linger/pulse-agent"} { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { return false } @@ -1360,7 +1377,7 @@ func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { } script := string(raw) for _, required := range []string{ - `PULSE_ROOTLESS_UBUNTU_IMAGE`, `^ubuntu@sha256:`, `--network none`, `--tmpfs /run`, + `PULSE_ROOTLESS_UBUNTU_IMAGE`, `^ubuntu@sha256:`, `--network none`, `--cgroupns=private`, `--tmpfs /run`, `PULSE_SECURE_RUNTIME_ROOTLESS_QUALIFICATION=disposable-v1`, `dockeragent.test`, `run_runtime docker`, `run_runtime podman`, `PULSE_ROOTLESS_RUNTIME=${runtime_name}`, `--privileged`, `pulse-secure-runtime-rootless-qualification`, `qualification result != \"passed\"`, @@ -1373,7 +1390,7 @@ func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { t.Fatalf("rootless qualification wrapper missing %q", required) } } - for _, forbidden := range []string{"/var/run/docker.sock:", "/run/podman/podman.sock:", "-v $", "--volume"} { + for _, forbidden := range []string{"/var/run/docker.sock:", "/run/podman/podman.sock:", "/sys/fs/cgroup:/sys/fs/cgroup", "--cgroupns=host", "-v $", "--volume"} { if strings.Contains(script, forbidden) { t.Fatalf("rootless qualification wrapper contains forbidden host-runtime mount marker %q", forbidden) } diff --git a/scripts/run-secure-runtime-rootless-qualification.sh b/scripts/run-secure-runtime-rootless-qualification.sh index b2560cee6..ee3d5f97f 100755 --- a/scripts/run-secure-runtime-rootless-qualification.sh +++ b/scripts/run-secure-runtime-rootless-qualification.sh @@ -225,9 +225,9 @@ run_runtime() { container_id="$(docker create --name "${container_name}" --hostname "pulse-rootless-${runtime_name}" \ --label "${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}" \ - --privileged --network none --cgroupns=host \ + --privileged --network none --cgroupns=private \ --tmpfs /run:rw,nosuid,nodev,mode=755 --tmpfs /run/lock:rw,nosuid,nodev,mode=755 \ - -v /sys/fs/cgroup:/sys/fs/cgroup:rw "${IMAGE_TAG}")" + "${IMAGE_TAG}")" CONTAINER_IDS+=("${container_id}") docker cp "${PACKET_DIR}/." "${container_id}:/opt/pulse/packet" docker start "${container_id}" >/dev/null