Skip to main content

max / alloy

Label the target's /etc, without which no Alloy machine can layer a package Found while verifying the component flip in a VM, and it is a bigger defect than the six files it touches. An installed machine had exactly six mislabelled files, every one written by the configure stages: passwd, shadow, group, gshadow and .pwd.lock as etc_t where the policy says passwd_file_t and shadow_t, and hostname as var_run_t. The rest of /etc and all of /usr were already correct, so this is the installer failing to clean up after itself rather than an image that needs relabelling. systemd cannot take the /etc/.pwd.lock lock when it is etc_t, so every unit with DynamicUser=yes fails to start with "Failed to update dynamic user credentials: Permission denied" and no AVC in the journal, because the denial is dontaudited. rpm-ostreed is one of those units. So the machine cannot layer a package at all: it cannot install its own console and terminal on first boot, and it could never receive a hotfix. The whole RPM channel is inert on a machine in this state. Confirmed by comparison rather than by reasoning: a stock fedora-bootc:43 installed from the same SELinux-less host, also enforcing, labels these files correctly and lays packages down fine. So it is ours, not bootc's and not the build host's. Relabelling just those six files makes DynamicUser work again immediately. Why they are missed: the account stages run through in_target, which is chroot, and libselinux looks for selinuxfs at /sys/fs/selinux. The chroot has no /sys, so shadow-utils concludes SELinux is off and writes files without asking the policy what they should be. systemd-firstboot --root misses /etc/hostname the same way. setfiles -r from the live system rather than a restorecon inside the chroot, for that same reason: out here it can see selinuxfs, and -r makes it match paths with the target prefix stripped, so the policy is consulted about /etc/passwd rather than about /mnt/.../etc/passwd, which matches nothing and would relabel nothing while exiting 0. Skipped rather than failed when the live system has no selinuxfs, which is the selinux=0 GRUB entry. An install from that entry already produces an unlabelled machine needing enforcing=0 typed at GRUB, and build/make-iso.sh documents it as the escape hatch for a live system that will not boot otherwise. Failing the install there would take the hatch away rather than fix anything.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 01:10 UTC
Signed with PGP, not checked
Commit: 5bcba0b3bf07decb368ba6971aba09965dba6fc0
Parent: cad26d5
1 file changed, +81 insertions, -0 deletions
@@ -2101,6 +2101,10 @@
2101 2101 // while the volume is open stay last.
2102 2102 stages.extend(update_timer_stages(updates_scheduled(), root));
2103 2103
2104 + // Last of the stages that touch the target's /etc, because it fixes what
2105 + // all of them wrote.
2106 + stages.extend(relabel_stages(root));
2107 +
2104 2108 // Before finalize and umount, because the TPM slot that authorizes these is
2105 2109 // only guaranteed to answer while the volume this deployment sits in is
2106 2110 // still open.
@@ -2326,6 +2330,75 @@
2326 2330 )]
2327 2331 }
2328 2332
2333 + /// Give the target's `/etc` the SELinux labels the configure stages did not.
2334 + ///
2335 + /// **Measured 2026-08-14, in a VM, and it broke more than it looks like.** An
2336 + /// installed machine had exactly six mislabelled files, every one of them
2337 + /// written by the stages above: `passwd`, `shadow`, `group`, `gshadow` and
2338 + /// `.pwd.lock` as `etc_t` where the policy says `passwd_file_t`/`shadow_t`, and
2339 + /// `hostname` as `var_run_t`. Everything else in `/etc`, and all of `/usr`,
2340 + /// was already correct, so this is not a relabel of the image. It is the
2341 + /// installer cleaning up after itself.
2342 + ///
2343 + /// The consequence is out of all proportion to six files. systemd cannot take
2344 + /// the `/etc/.pwd.lock` lock when it is `etc_t`, so **every unit with
2345 + /// `DynamicUser=yes` fails to start**, with `Failed to update dynamic user
2346 + /// credentials: Permission denied` and no AVC in the journal, because the
2347 + /// denial is dontaudited. `rpm-ostreed.service` is one of those units. So the
2348 + /// machine cannot layer a package, which means it cannot install its own
2349 + /// console and terminal on first boot (`alloy-layer-components.service`) and
2350 + /// could never receive a hotfix. Confirmed by comparison: a stock
2351 + /// `fedora-bootc:43` installed from the same host, also enforcing, labels these
2352 + /// files correctly and lays packages down fine.
2353 + ///
2354 + /// Why the labels are missed in the first place: the account stages run through
2355 + /// [`in_target`], which is `chroot`. libselinux looks for selinuxfs at
2356 + /// `/sys/fs/selinux`, the chroot has no `/sys` mounted, so shadow-utils
2357 + /// concludes SELinux is off and writes files without asking the policy what
2358 + /// they should be. `systemd-firstboot --root` misses `/etc/hostname` the same
2359 + /// way.
2360 + ///
2361 + /// `setfiles -r` rather than a `restorecon` inside the chroot, for that exact
2362 + /// reason: run from the live system it can see selinuxfs, and `-r` makes it
2363 + /// match paths with the target prefix stripped, so the policy is consulted
2364 + /// about `/etc/passwd` rather than about `/mnt/.../etc/passwd`, which matches
2365 + /// nothing and would relabel nothing while exiting 0.
2366 + ///
2367 + /// Skipped, rather than failed, when the live system has no selinuxfs. That is
2368 + /// the `selinux=0` GRUB entry, which exists as the escape hatch for a live
2369 + /// system that will not boot otherwise (build/make-iso.sh). An install from it
2370 + /// already produces an unlabelled machine that needs `enforcing=0` typed at
2371 + /// GRUB, and that is documented; failing the install here would take the escape
2372 + /// hatch away rather than fix anything. The probe is explicit and prints which
2373 + /// way it went, so a skip is a line in the log rather than a silence.
2374 + fn relabel_stages(root: &str) -> Vec<Stage> {
2375 + let target = root.to_string();
2376 + vec![Stage::Resolve {
2377 + invocation: Invocation::new("sh")
2378 + .arg("-c")
2379 + .arg("test -d /sys/fs/selinux && echo enforcing-policy-present || echo no-selinuxfs"),
2380 + then: Box::new(move |probe| {
2381 + if probe.trim() != "enforcing-policy-present" {
2382 + return Ok(Vec::new());
2383 + }
2384 + Ok(vec![Stage::Run(
2385 + Invocation::new("setfiles")
2386 + .arg("-r")
2387 + .arg(&target)
2388 + .arg(format!("{target}{FILE_CONTEXTS}"))
2389 + .arg(format!("{target}/etc")),
2390 + )])
2391 + }),
2392 + }]
2393 + }
2394 +
2395 + /// Where the targeted policy keeps the path-to-label map.
2396 + ///
2397 + /// Named as a constant because [`relabel_stages`] has to say it twice, once
2398 + /// with the target prefix for the file it reads and once without, and the two
2399 + /// drifting apart would produce a `setfiles` that reads the wrong map or none.
2400 + const FILE_CONTEXTS: &str = "/etc/selinux/targeted/contexts/files/file_contexts";
2401 +
2329 2402 /// The `--source-imgref` for this run, or `None` when bootc's own default
2330 2403 /// applies.
2331 2404 ///
@@ -6521,6 +6594,14 @@
6521 6594 if line.starts_with("bootc") || line.starts_with("umount") {
6522 6595 continue;
6523 6596 }
6597 + // So does the selinuxfs probe in [`relabel_stages`], and more
6598 + // completely: it is the one stage whose question is about the live
6599 + // system rather than about the target. The `setfiles` it decides on
6600 + // does name the deployment, and is checked by this loop like
6601 + // everything else.
6602 + if line.starts_with("sh -c") {
6603 + continue;
6604 + }
6524 6605 assert!(
6525 6606 line.contains("/ostree/deploy/"),
6526 6607 "configured the sysroot instead of the deployment: {line}"