[PATCH v2 02/13] classes: add container-nonroot-user.bbclass
Tim Orling <[email protected]> Mon, 6 Jul 2026 10:12:37 -0700
| Newsgroups | org.yoctoproject.lists.meta-virtualization |
|---|---|
| Message-ID | <9ed44af1a2d173d353d1ce81710f1cd6e66203d1.1783356922.git.tim.orling@konsulko.com> |
For secure and production environments, we want to run containers as a non-root user. Some applications, such as Python, require a $HOME directory with proper permissions. Because OCI_LAYERS :directories: copies with 'cp -a --no-preserve=ownership', we need a fixup function to create the proper permissions and ownership in a new raw layer. Process a list of NONROOT_OWNED_DIRS which will also be added as owned by the NONROOT_USER. Add oci_nonroot_inject_user() to do_image_oci[prefuncs] to inject 'nonroot' user into '/etc/passwd' when a layer is present which provides '/etc/passwd', since 'extrausers' acts on IMAGE_ROOTFS and not the 'base-passwd' package. The behavior here is inspired by dhi.io/python:3 and by dhi.io/valkey:9 (Docker Hardened Images). Signed-off-by: Tim Orling <[email protected]> --- classes/container-nonroot-user.bbclass | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 classes/container-nonroot-user.bbclass diff --git a/classes/container-nonroot-user.bbclass b/classes/container-nonroot-user.bbclass new file mode 100644 index 00000000..09826536 --- /dev/null +++ b/classes/container-nonroot-user.bbclass @@ -0,0 +1,155 @@ +# For secure and production environments, we want to run containers as a +# non-root user. Some applications, such as Python, require a $HOME +# directory with proper permissions. Because OCI_LAYERS :directories: +# copies with 'cp -a --no-preserve=ownership', we need a fixup function +# to create the proper permissions and ownership in a new raw layer. + +# The behavior here is inspired by dhi.io/python:3 (Docker Hardened Image) + +inherit extrausers + +# NONROOT_USER must be a bare identifier (no quotes or backslash, etc.) +NONROOT_USER ?= "nonroot" +NONROOT_UID ?= "65532" +NONROOT_GID ?= "65532" +# Space-separated absolute paths to create in the image, owned by nonroot. +NONROOT_OWNED_DIRS ?= "" + +# --------------------------------------------------------------------------- +# Create the unprivileged "nonroot" user (uid 65532, group 65532) +# --------------------------------------------------------------------------- +EXTRA_USERS_PARAMS += "\ + groupadd -g ${NONROOT_GID} ${NONROOT_USER}; \ + useradd -m -u ${NONROOT_UID} -g ${NONROOT_GID} \ + -d /home/${NONROOT_USER} ${NONROOT_USER}; \ +" + +# Allow a container to choose to run as 'root' +OCI_IMAGE_RUNTIME_UID ?= "${NONROOT_UID}" +OCI_IMAGE_ENV_VARS = "HOME=/home/${NONROOT_USER}" + +# In multi-layer OCI mode the image is assembled from per-layer package +# installs (oci_multilayer_install_packages in image-oci-umoci.inc), not from +# IMAGE_ROOTFS. extrausers/EXTRA_USERS_PARAMS only edits IMAGE_ROOTFS, so the +# nonroot account never reaches the image. Inject it into the layer rootfs that +# ships /etc/passwd, before IMAGE_CMD:oci assembles the layers. +python oci_nonroot_inject_user() { + import os + + if (d.getVar('OCI_LAYER_MODE') or 'single') != 'multi': + return # single-layer mode builds from IMAGE_ROOTFS; extrausers handles it + + user = d.getVar('NONROOT_USER') + uid = d.getVar('NONROOT_UID') + gid = d.getVar('NONROOT_GID') + home = '/home/%s' % user + + # shell field is cosmetic (runtime user is pinned numerically via + # OCI config.user); /bin/sh matches dhi.io passwd entries. + passwd_line = '%s:x:%s:%s:%s:%s:/bin/sh\n' % (user, uid, gid, user, home) + group_line = '%s:x:%s:\n' % (user, gid) + shadow_line = '%s:!:::::::\n' % user + + def append_once(path, line, key): + if not os.path.exists(path): + return False + with open(path) as f: + if any(l.startswith(key) for l in f): + return True # already present (idempotent rebuild) + with open(path, 'a') as f: + f.write(line) + return True + + key = user + ':' + count = int(d.getVar('OCI_LAYER_COUNT') or 0) + found = False + for i in range(1, count + 1): + rootfs = d.getVar('OCI_LAYER_%d_ROOTFS' % i) + if not rootfs: + continue + # add to every layer that carries /etc/passwd so the topmost wins too + if append_once(os.path.join(rootfs, 'etc/passwd'), passwd_line, key): + append_once(os.path.join(rootfs, 'etc/group'), group_line, key) + append_once(os.path.join(rootfs, 'etc/shadow'), shadow_line, key) # optional + found = True + + if not found: + bb.warn("container-nonroot-user: no layer ships /etc/passwd; '%s' not " + "added — is base-passwd in a packages: layer?" % user) +} + +# Must run AFTER oci_multilayer_install_packages populates OCI_LAYER_*_ROOTFS. +do_image_oci[prefuncs] += "oci_nonroot_inject_user" + +# Make sure we can write to e.g. /home/nonroot/.python_history +# using :directories: in OCI_LAYERS does not preserve permissions. +fakeroot fix_oci_home_perms() { + cd ${IMGDEPLOYDIR} + image_name="${IMAGE_NAME}${IMAGE_NAME_SUFFIX}-oci" + layer_tar="${WORKDIR}/oci-home-fix-layer.tar" + + rm -f "$layer_tar" + + # BitBake expands ${NONROOT_USER} etc. at parse time *before* + # shell sees the body, so single quoted 'PYEOF' is okay. + python3 - "$layer_tar" <<'PYEOF' +import sys, tarfile, time + +layer_tar = sys.argv[1] +mtime = int(time.time()) + +uid = ${NONROOT_UID} +gid = ${NONROOT_GID} + +# (path, mode, uid, gid) -- paths are tar-relative, no leading slash +entries = [ + ("home", 0o755, 0, 0), + ("home/${NONROOT_USER}", 0o700, uid, gid), +] + +seen = {entry[0] for entry in entries} +for nonrootdir in "${NONROOT_OWNED_DIRS}".split(): + parts = nonrootdir.strip("/").split("/") + for i, _ in enumerate(parts): + path = "/".join(parts[:i+1]) + if path in seen: + continue + seen.add(path) + leaf = (i == len(parts) - 1) + # leaf -> nonroot-owned; parents -> root:root, just to scaffold the path + entries.append((path, 0o755, uid if leaf else 0, gid if leaf else 0)) + +with tarfile.open(layer_tar, "w") as tar: + for name, mode, uid, gid in entries: + info = tarfile.TarInfo(name=name) + info.type = tarfile.DIRTYPE + info.mode = mode + info.uid = uid + info.gid = gid + info.uname = "" # numeric-only; let umoci canonicalize + info.gname = "" + info.mtime = mtime + tar.addfile(info) +PYEOF + + umoci raw add-layer --image "$image_name:${OCI_IMAGE_TAG}" "$layer_tar" + rm -f "$layer_tar" + + # Adding the raw layer mutates the OCI image directory, so the tar outputs + # produced by do_image_oci are now stale and must be rebuilt. The image + # *directory* ($image_name) is the source of truth; the .tar files are + # derived from it and only exist when OCI_IMAGE_TAR_OUTPUT is set, so mirror + # that gating (see image-oci-umoci.inc) instead of rebuilding unconditionally. + # Guard the assumption: an OCI layout always has an index.json, so its + # absence means the packaging layout in image-oci.bbclass has changed (or + # the dir is empty). Fail loudly rather than emit a silently-broken tarball. + if [ -n "${OCI_IMAGE_TAR_OUTPUT}" ]; then + if [ ! -f "$image_name/index.json" ]; then + bbfatal "fix_oci_home_perms: '$image_name' is not an OCI image layout (no index.json) in ${IMGDEPLOYDIR}; image-oci packaging layout may have changed" + fi + rm -f "$image_name.tar" "$image_name-dir.tar" + ( cd "$image_name" && tar -cf "../$image_name.tar" "." ) + tar -cf "$image_name-dir.tar" "$image_name" + fi +} +do_image_oci[postfuncs] += "fix_oci_home_perms" -- 2.54.0