Skip to content

Commit aef71bf

Browse files
committed
generator: Add bootc-early-overlay-relabel for transient overlay SELinux fix
Transient overlays (/) inherit tmpfs_t from the upper dir's tmpfs via fs_use_trans at SELinux policy-load time. Add a generator-emitted oneshot unit, bootc-early-overlay-relabel.service, that runs 'bootc internals relabel-overlay-mountpoints' before sysinit.target to restore the correct label on each writable overlayfs mount point. Two detection paths, both needed because the generator runs before local-fs.target: - Root writability: inspect the mount source for the "transient:composefs=" prefix to detect a transient root overlay. - Subdir mounts (/etc): bootc-root-setup.service mounts these after the generator, so we read setup-root-conf.toml directly from the booted image to know whether /etc will be a transient overlay. The detection block runs before the OSTREE_BOOTED guard: native composefs boots do not write /run/ostree-booted, but still need the relabel unit. relabel_overlay_mountpoints() checks both OVERLAYFS_SUPER_MAGIC and !RDONLY to distinguish writable transient overlays from the read-only composefs root (both are overlayfs, only the former needs relabelling). Assisted-by: OpenCode (claude-sonnet-4-6@default) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 75f3676 commit aef71bf

6 files changed

Lines changed: 205 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/initramfs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ rustix.workspace = true
1414
serde = { workspace = true, features = ["derive"] }
1515
composefs-ctl.workspace = true
1616
toml.workspace = true
17+
tracing.workspace = true
1718
fn-error-context.workspace = true
1819
bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" }
1920

crates/initramfs/src/lib.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,39 @@ struct Config {
119119
root: RootConfig,
120120
}
121121

122+
/// Default path to the setup-root configuration file, relative to the booted root.
123+
pub const SETUP_ROOT_CONF_PATH: &str = "/usr/lib/composefs/setup-root-conf.toml";
124+
125+
/// Returns `true` if the configuration at `path` requests a transient `/etc`
126+
/// overlay. Used by the systemd generator to decide whether to emit the
127+
/// SELinux relabel unit *before* those mounts exist (the generator runs before
128+
/// `local-fs.target`).
129+
///
130+
/// Returns `false` if the file is absent or unreadable (safe default: no unit
131+
/// emitted for non-transient systems).
132+
pub fn config_has_transient_submounts(path: &std::path::Path) -> bool {
133+
let text = match std::fs::read_to_string(path) {
134+
Ok(t) => t,
135+
Err(e) => {
136+
tracing::debug!("Could not read {}: {e:#}", path.display());
137+
return false;
138+
}
139+
};
140+
let config: Config = match toml::from_str(&text) {
141+
Ok(c) => c,
142+
Err(e) => {
143+
tracing::debug!("Could not parse {}: {e:#}", path.display());
144+
return false;
145+
}
146+
};
147+
// Only /etc overlay triggers the relabel unit.
148+
let is_transient = |mc: &MountConfig| match mc.mount {
149+
Some(mt) => mt == MountType::Transient,
150+
None => mc.transient,
151+
};
152+
is_transient(&config.etc)
153+
}
154+
122155
/// Command-line arguments
123156
#[derive(Parser, Debug)]
124157
pub struct Args {

crates/lib/src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,9 @@ pub(crate) enum InternalsOpts {
649649
/// Relabel this path
650650
path: Utf8PathBuf,
651651
},
652+
/// Relabel the overlay mount point inodes after SELinux policy load.
653+
/// Called by the generated bootc-early-overlay-relabel unit.
654+
RelabelOverlayMountpoints,
652655
/// Proxy frontend for the `ostree-ext` CLI.
653656
OstreeExt {
654657
#[clap(allow_hyphen_values = true)]
@@ -2112,6 +2115,9 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
21122115
crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?;
21132116
Ok(())
21142117
}
2118+
InternalsOpts::RelabelOverlayMountpoints => {
2119+
crate::generator::relabel_overlay_mountpoints()
2120+
}
21152121
InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => {
21162122
let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
21172123
crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await

crates/lib/src/generator.rs

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use std::io::BufRead;
22

33
use anyhow::{Context, Result};
4-
use camino::Utf8PathBuf;
4+
use camino::{Utf8Path, Utf8PathBuf};
55
use cap_std::fs::Dir;
66
use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
77
use fn_error_context::context;
88
use ostree_ext::container_utils::{OSTREE_BOOTED, is_ostree_booted_in};
9+
use ostree_ext::{gio, ostree};
910
use rustix::{fd::AsFd, fs::StatVfsMountFlags};
1011

1112
use crate::install::DESTRUCTIVE_CLEANUP;
@@ -17,6 +18,8 @@ const MULTI_USER_TARGET: &str = "multi-user.target";
1718
const EDIT_UNIT: &str = "bootc-fstab-edit.service";
1819
const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
1920
pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
21+
const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
22+
const SYSINIT_TARGET: &str = "sysinit.target";
2023

2124
/// Called when the root is read-only composefs to reconcile /etc/fstab
2225
#[context("bootc generator")]
@@ -86,7 +89,53 @@ pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()>
8689

8790
/// Main entrypoint for the generator
8891
pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
89-
// Only run on ostree systems
92+
// === Relabel unit: runs for ALL composefs boots (native or ostree) ===
93+
// Must be before the ostree-booted guard because native composefs boots do
94+
// not write /run/ostree-booted, but still need the relabel unit when any
95+
// transient overlay is active.
96+
//
97+
// Gate on the root being overlayfs (composefs always mounts an overlay, so
98+
// this excludes non-composefs systems without needing the ostree-booted marker).
99+
//
100+
// Two triggering conditions, detected independently:
101+
//
102+
// 1. Transient root: the initramfs sets the overlay source to
103+
// "transient:composefs=<digest>" in /proc/self/mountinfo. Detect via
104+
// inspect_filesystem() rather than fstatvfs() because the `ro` kernel
105+
// cmdline flag can make an otherwise-writable overlay appear read-only
106+
// at generator time.
107+
//
108+
// 2. Transient /etc: this is mounted by bootc-root-setup.service
109+
// which runs *after* the generator, so fstatvfs would see the read-only
110+
// composefs at generator time. Read setup-root-conf.toml directly from
111+
// the booted image instead.
112+
{
113+
let st = rustix::fs::fstatfs(root.as_fd())?;
114+
if st.f_type == libc::OVERLAYFS_SUPER_MAGIC {
115+
let root_is_transient =
116+
match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
117+
Ok(fs) => fs.source.starts_with("transient:composefs="),
118+
Err(e) => {
119+
tracing::debug!("Could not inspect root filesystem: {e:#}");
120+
false
121+
}
122+
};
123+
let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts(
124+
std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH),
125+
);
126+
if root_is_transient || submounts_are_transient {
127+
tracing::debug!(
128+
root_is_transient,
129+
submounts_are_transient,
130+
"Transient overlay detected; generating relabel unit"
131+
);
132+
generate_transient_overlay_relabel(unit_dir)?;
133+
}
134+
}
135+
}
136+
137+
// === Ostree-specific generator logic ===
138+
// Only run on ostree systems (native composefs boots skip below).
90139
if !root.try_exists(OSTREE_BOOTED)? {
91140
return Ok(());
92141
}
@@ -97,15 +146,17 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
97146

98147
unit_enablement_impl(sysroot, unit_dir)?;
99148

100-
// Also only run if the root is a read-only overlayfs (a composefs really)
149+
// Only run for overlayfs roots (composefs mounts an overlay, regular or transient).
101150
let st = rustix::fs::fstatfs(root.as_fd())?;
102151
if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
103152
tracing::trace!("Root is not overlayfs");
104153
return Ok(());
105154
}
155+
156+
// The fstab editor only applies to read-only composefs roots (not transient).
106157
let st = rustix::fs::fstatvfs(root.as_fd())?;
107158
if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
108-
tracing::trace!("Root is writable");
159+
tracing::trace!("Root is writable, skipping fstab generator");
109160
return Ok(());
110161
}
111162

@@ -137,6 +188,58 @@ ExecStart=bootc internals fixup-etc-fstab\n\
137188
Ok(())
138189
}
139190

191+
/// Generate a oneshot service that relabels the transient overlay inode
192+
/// after SELinux policy loads, fixing the tmpfs_t label SELinux assigns to
193+
/// overlay upper-dir inodes at policy-load time.
194+
fn generate_transient_overlay_relabel(unit_dir: &Dir) -> Result<()> {
195+
unit_dir.atomic_write(
196+
TRANSIENT_RELABEL_UNIT,
197+
include_str!("units/bootc-early-overlay-relabel.service"),
198+
)?;
199+
let wants = format!("{SYSINIT_TARGET}.wants");
200+
unit_dir.create_dir_all(&wants)?;
201+
unit_dir.symlink(
202+
&format!("../{TRANSIENT_RELABEL_UNIT}"),
203+
&format!("{wants}/{TRANSIENT_RELABEL_UNIT}"),
204+
)?;
205+
Ok(())
206+
}
207+
208+
/// Relabel transient overlay mount point inodes using the running SELinux policy.
209+
/// Called by the generated bootc-early-overlay-relabel.service oneshot to fix
210+
/// the tmpfs_t label that fs_use_trans assigns to overlay upper-dir inodes at
211+
/// policy-load time. Each of /, /etc, /var is relabelled iff it is a writable
212+
/// overlayfs (i.e. a transient overlay, not the read-only composefs).
213+
pub(crate) fn relabel_overlay_mountpoints() -> Result<()> {
214+
let policy = ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)
215+
.context("Loading SELinux policy")?;
216+
for path in ["/", "/etc", "/var"] {
217+
let dir = Dir::open_ambient_dir(path, cap_std::ambient_authority())
218+
.with_context(|| format!("Opening {path}"))?;
219+
let st = rustix::fs::fstatfs(dir.as_fd())?;
220+
if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
221+
tracing::trace!("{path} is not an overlayfs mount, skipping relabel");
222+
continue;
223+
}
224+
let stv = rustix::fs::fstatvfs(dir.as_fd())?;
225+
if stv.f_flag.contains(StatVfsMountFlags::RDONLY) {
226+
tracing::trace!("{path} is a read-only overlayfs (composefs), skipping relabel");
227+
continue;
228+
}
229+
let metadata = dir.metadata(".").with_context(|| format!("stat {path}"))?;
230+
crate::lsm::relabel(
231+
&dir,
232+
&metadata,
233+
Utf8Path::new("."),
234+
Some(Utf8Path::new(path)),
235+
&policy,
236+
)
237+
.with_context(|| format!("Relabelling {path}"))?;
238+
tracing::debug!("Relabelled {path}");
239+
}
240+
Ok(())
241+
}
242+
140243
#[cfg(test)]
141244
mod tests {
142245
use camino::Utf8Path;
@@ -241,6 +344,51 @@ mod tests {
241344
Ok(())
242345
}
243346

347+
#[test]
348+
fn test_transient_overlay_relabel_generated() -> Result<()> {
349+
let tempdir = fixture()?;
350+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
351+
352+
// We can't fake fstatfs or findmnt, so call generate_transient_overlay_relabel directly.
353+
generate_transient_overlay_relabel(unit_dir)?;
354+
355+
// The unit file must exist
356+
assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
357+
// The symlink in sysinit.target.wants must point at the generated unit
358+
let wants = format!("{SYSINIT_TARGET}.wants");
359+
let link = unit_dir.read_link_contents(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
360+
let link: camino::Utf8PathBuf = link.try_into().unwrap();
361+
assert_eq!(link, format!("../{TRANSIENT_RELABEL_UNIT}"));
362+
// The unit must invoke bootc internals relabel-overlay-mountpoints
363+
let content = unit_dir.read_to_string(TRANSIENT_RELABEL_UNIT)?;
364+
assert!(
365+
content.contains("ExecStart=bootc internals relabel-overlay-mountpoints"),
366+
"unexpected unit content: {content}"
367+
);
368+
369+
Ok(())
370+
}
371+
372+
#[test]
373+
fn test_transient_overlay_relabel_idempotent() -> Result<()> {
374+
let tempdir = fixture()?;
375+
let unit_dir = &tempdir.open_dir("run/systemd/system")?;
376+
377+
// Calling generate_transient_overlay_relabel twice must succeed
378+
generate_transient_overlay_relabel(unit_dir)?;
379+
// Second call: atomic_write overwrites the unit file; symlink already exists
380+
// (symlink won't be re-created because the dir already contains it).
381+
// The test just checks the call doesn't error.
382+
// We need to remove the old symlink first (same as how enable_unit does it).
383+
let wants = format!("{SYSINIT_TARGET}.wants");
384+
unit_dir.remove_file_optional(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
385+
generate_transient_overlay_relabel(unit_dir)?;
386+
387+
assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
388+
389+
Ok(())
390+
}
391+
244392
#[test]
245393
fn test_generator_fstab_idempotent() -> Result<()> {
246394
let anaconda_fstab = indoc::indoc! { "
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[Unit]
2+
Description=Fix SELinux labels on transient overlay mount points
3+
Documentation=man:bootc(1)
4+
DefaultDependencies=no
5+
ConditionSecurity=selinux
6+
After=local-fs.target
7+
Before=sysinit.target
8+
9+
[Service]
10+
Type=oneshot
11+
RemainAfterExit=yes
12+
ExecStart=bootc internals relabel-overlay-mountpoints

0 commit comments

Comments
 (0)