Skip to content

feat(runtime): user-namespace remap behind a default-off flag (#126, phase 1) - #181

Open
luthermonson wants to merge 3 commits into
mainfrom
feat/userns-remap-126
Open

feat(runtime): user-namespace remap behind a default-off flag (#126, phase 1)#181
luthermonson wants to merge 3 commits into
mainfrom
feat/userns-remap-126

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Implements the mechanism for issue #126 — running each runner container in a remapped user namespace so container uid 0 maps to a high, unprivileged host uid instead of real host root. This is the structural layer beneath the capability/seccomp/AppArmor containment: with it on, a successful container escape (a runc CVE, a kernel LPE, the dind bind TOCTOU) lands as an unprivileged host uid owning nothing, rather than as root on the VM.

Phase 1 (this PR) — the core, safe to land default-off

  • config [runtime.userns] { enabled, base_uid, base_gid, size }, DEFAULT OFF. Resolved() supplies base 1000000000 / size 65536, so an enabled-but-unconfigured pool can never map container root back onto host uid 0 (which would be no remap).
  • specoci.WithUserNamespace(uid/gid maps) via usernsSpecOpts, Linux-only, appended with the other spec-hardening opts.
  • snapshotclient.WithRemapperLabels on WithNewSnapshot, so the rootfs snapshot presents mapped ownership (idmapped mount where the overlay snapshotter supports it). Fail-closed: a host that can't honor the remap fails the snapshot prepare and the job errors, rather than silently running unmapped with container root == host root.

Default-off ⇒ a no-op for every existing pool. Validation is by enabling it on one pool on real hardware, exactly as the issue prescribes.

Not in this PR — follow-up, gated behind the same flag, needs on-metal validation

  • per-job runner-overlay lowerdir ownership for custom-image jobs (shared RO runner tree must present mapped ownership);
  • dind sibling-container mapping consistency + the bind-source chown (createUnder/translateBindSource in pkg/dind), so container: / -v jobs work under the remap;
  • the macOS virtio-fs ContainerDataDir path.

Standard Linux-image jobs (runner pre-installed in the image, no runner-mount overlay, no dind) are covered by the snapshot remap alone — that's the intended first canary target.

Tests

  • usernsSpecOpts: mapping + user-namespace assertions, incl. a guard that an enabled-but-unconfigured config resolves to a real base (never a degenerate map onto uid 0).
  • config load + Resolved() defaults + the load-bearing default-off assertion (an accidental default-on would remap every job on upgrade).

Validated: builds + vet clean on linux/windows/darwin (amd64+arm64); config tests pass; runtime test binary compiles. The Containment suite's #126 userns check is expected to flip PASS on a pool with [runtime.userns] enabled = true running a standard-image job.

…phase 1)

Adds the mechanism to run each runner container in a REMAPPED user
namespace so container uid 0 maps to a high, unprivileged host uid
instead of real host root. This is the structural layer beneath the
capability/seccomp/AppArmor containment: with it on, a container escape
(runc CVE, kernel LPE, the dind bind TOCTOU) lands as an unprivileged
host uid owning nothing, not as root on the VM.

Phase 1 — the core, safe to land:
- config: [runtime.userns] { enabled, base_uid, base_gid, size },
  DEFAULT OFF. Resolved() supplies base 1000000000 / size 65536 so an
  enabled-but-unconfigured pool can never map container root back onto
  host uid 0 (which would be no remap at all).
- spec: oci.WithUserNamespace(uid/gid maps) via usernsSpecOpts, Linux
  only, appended with the other spec-hardening opts.
- snapshot: client.WithRemapperLabels on WithNewSnapshot so the rootfs
  snapshot presents mapped ownership (idmapped mount where the overlay
  snapshotter supports it). FAIL-CLOSED: a host that cannot honor the
  remap fails the snapshot prepare and the job errors, rather than
  silently running unmapped with container root == host root.

Default-off means this is a no-op for every existing pool. Enable it on
one pool to validate on real hardware, per the issue.

NOT yet covered (follow-up, gated behind the same flag, needs on-metal
validation): the per-job runner overlay lowerdir ownership for
custom-image jobs, dind sibling-container mapping consistency and the
bind-source chown in pkg/dind, and the macOS virtio-fs path. Standard
Linux image jobs (runner pre-installed in the image, no runner-mount
overlay) are covered by the snapshot remap alone.

Tests: usernsSpecOpts mapping/namespace assertions (incl. the
resolved-default guard against a degenerate map onto uid 0); config
load + Resolved defaults + the load-bearing default-off assertion.
Review of #181 found the Userns field was only threaded into ONE of the
two runtime.New(runtime.Config{}) construction sites — the in-VM worker
branch. The host/native-Linux serve path (cmd/ephemerd/main.go:641), which
is what actually runs job containers on a Proxmox Linux node, was missing
it. A prior replace_all matched only the 3-tab-indented site, not the
2-tab one.

Effect (no safety impact, but defeats the feature): enabling
[runtime.userns] on a native-Linux pool left the value stranded at the
zero (disabled) value, so every job ran UNMAPPED with no error and no
log — the silent-no-op the fail-closed design is meant to avoid, and it
would make on-metal validation on a Linux pool validate nothing. The
missing field defaults OFF, the safe direction, so default-off integrity
is unaffected.

Also: collapse the double Userns.Resolved() call in the create log to one
local, and add TestUsernsSnapshotOpts asserting the remapper labels are
absent when disabled and carry the same base+size as the spec maps when
enabled (guards a transposed/half-applied remap that unit-passes today).
@luthermonson

Copy link
Copy Markdown
Contributor Author

Review: verified the two properties this PR promises. (1) Genuinely default-off: RuntimeUserns.Enabled is a plain bool whose zero value is false, an omitted [runtime.userns] block loads as off (pinned by TestLoad_RuntimeUserns_OmittedIsOff), and Resolved() never flips Enabled. (2) Inert when off: usernsSpecOpts/usernsSnapshotOpts both return nil for Enabled=false on every platform, so the OCI spec and WithNewSnapshot calls are byte-identical to today's; the only Create-path change when disabled is a variadic no-op. The enabled path's spec maps and snapshot remapper labels use the same base+size (pinned by test), and the fail-closed behavior on a snapshotter without idmapped-mount support is the right posture for a security control.

Not merging right now for one reason only: after updating the branch with main, the refreshed CI run (33465274049) is stuck queued because the runner fleet currently has zero online runners (org runner list is empty; CI for #184 and the post-merge image build for #183 are queued behind the same outage). The pre-update pull_request run (32690416072) was fully green including the Windows and macOS build legs, and the branch is conflict-free with main. The Build (Windows amd64) failure visible in this PR's checks came from the v0.2.7-rc1 Release run and is runner-infra death ("A device which does not exist was specified" on the runner file commands), not a code failure. Once the fleet is back and this run goes green, this is mergeable as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant