Isolate rootless qualification cgroups

This commit is contained in:
rcourtman
2026-09-01 16:37:11 +01:00
parent 5e471c1dd5
commit f44968e6c6
4 changed files with 32 additions and 8 deletions
@@ -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,
@@ -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,
@@ -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)
}
@@ -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