max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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}" |