Boxr's rootless isolation model: user namespaces, a pre-Tokio trampoline, and no root daemon #397
kchaitanya863
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
I've been building Boxr, a rootless OCI container engine in Rust, and after the networking stack (Discussion #393), the piece I want to put under the microscope next is the isolation model: how a container gets "root inside, nobody outside" with no sudo, no root daemon, and no setuid binary of my own.
The core trick is boring on purpose: Linux user namespaces. The container's uid 0 maps to your unprivileged host uid. Everything that looks privileged inside the container — mounting, chrooting, creating network namespaces — is unprivileged outside it, because the kernel checks capabilities against the userns, not the host. If a container process escapes its namespaces, it's still just you.
The part that surprised me is how much ceremony it takes to get there. The kernel rejects
unshare(CLONE_NEWUSER)withEINVALfrom a multi-threaded process, and my main binary runs on Tokio. Somain.rsintercepts a hidden__internal-trampolinesubcommand before the runtime is built, and the real setup happens in a single-threaded re-exec of myself. Then there's a fork + socketpair dance:newuidmap/newgidmapwith/etc/subuidranges, falling back to writing/proc/<pid>/uid_mapdirectly (single mapping: container root → your uid;setgroupsgets "deny" first, which the kernel requires).Inside, it's the standard OCI playbook, done carefully:
pivot_root(withchrootfallback), fresh proc/sys/dev (sysfs falls back to tmpfs since restricted userns often can't mount new sysfs), device nodes bind-mounted from the host, bind mounts validated with a canonicalize +starts_withescape check, generated resolv.conf/hosts. Then lockdown before exec: a seccomp-BPF filter, capability bounding-set drops for anything outside the OCI bounding set, setuid/setgid down to the spec's user, environment scrubbed. Cgroups v2 limits attach to your systemd user slice when it exists. Children getPR_SET_PDEATHSIGso nothing outlives the parent.Honest caveats — the parts I lose sleep over:
--memoryflag does nothing. Silent is arguably worse than failing.newuidmap/newgidmapwhen present. The fallback path (direct/procwrites, single-UID map) uses no setuid anything. Whether the convenience of subordinate UID ranges is worth keeping setuid binaries in the trust path is an open question.What I'd love feedback on:
newuidmap/newgidmappath entirely and always use the direct single-UID/procfallback — fewer moving parts, zero setuid in the trust path — at the cost of multi-UID fidelity (thinkapt's_aptuser)?run --memorywhen the hierarchy isn't delegated, or keep the current silent best-effort?~28k lines of Rust, single maintainer, beta quality. The isolation code lives in
src/runtime/linux.rs(run_trampoline) andsrc/security/mod.rs. Happy to walk through any of it — especially the parts above that I'm unsure about.All reactions