diff --git a/lib/src/install.rs b/lib/src/install.rs index 3a26db23..c754a09f 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -11,6 +11,7 @@ mod baseline; use std::io::BufWriter; use std::io::Write; use std::os::fd::AsFd; +use std::os::unix::fs::DirBuilderExt; use std::os::unix::process::CommandExt; use std::process::Command; use std::str::FromStr; @@ -21,11 +22,15 @@ use anyhow::{anyhow, Context, Result}; use camino::Utf8Path; use camino::Utf8PathBuf; use cap_std::fs::Dir; +use cap_std_ext::cap_primitives; use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::DirBuilder; +use cap_std_ext::cap_std::io_lifetimes::AsFilelike; use cap_std_ext::prelude::CapStdExtDirExt; use chrono::prelude::*; use clap::ValueEnum; use ostree_ext::oci_spec; +use rustix::fd::AsRawFd; use rustix::fs::FileTypeExt; use rustix::fs::MetadataExt; @@ -38,6 +43,7 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::containerenv::ContainerExecutionInfo; +use crate::lsm::Labeler; use crate::task::Task; use crate::utils::sigpolicy_from_opts; @@ -124,6 +130,27 @@ pub(crate) struct InstallConfigOpts { #[serde(default)] pub(crate) disable_selinux: bool, + /// Inject arbitrary files into the target deployment `/etc`. One can use + /// this for example to inject systemd units, or `tmpfiles.d` snippets + /// which set up SSH keys. + /// + /// Files injected this way become "unmanaged state"; they will be carried + /// forward across upgrades, but will not otherwise be updated unless + /// a secondary mechanism takes ownership thereafter. + /// + /// This option can be specified multiple times; the files will be copied + /// in order. + /// + /// Any missing parent directories will be implicitly created with root ownership + /// and mode 0755. + /// + /// This option pairs well with additional bind mount + /// volumes set up via the container orchestrator, e.g.: + /// `podman run ... -v /path/to/config:/tmp/etc bootc install to-disk --copy-etc /tmp/etc` + #[clap(long)] + #[serde(default)] + pub(crate) copy_etc: Option>, + // Only occupy at most this much space (if no units are provided, GB is assumed). // Using this option reserves space for partitions created dynamically on the // next boot, or by subsequent tools. @@ -564,11 +591,16 @@ kargs = ["console=ttyS0", "foo=bar"] } } +struct DeploymentComplete { + aleph: InstallAleph, + deployment: Dir, +} + #[context("Creating ostree deployment")] async fn initialize_ostree_root_from_self( state: &State, root_setup: &RootSetup, -) -> Result { +) -> Result { let rootfs_dir = &root_setup.rootfs_fd; let rootfs = root_setup.rootfs.as_path(); let cancellable = gio::Cancellable::NONE; @@ -714,7 +746,10 @@ async fn initialize_ostree_root_from_self( kernel: uname.release().to_str()?.to_string(), }; - Ok(aleph) + Ok(DeploymentComplete { + aleph, + deployment: root, + }) } #[context("Copying to oci")] @@ -1058,6 +1093,63 @@ async fn prepare_install( Ok(state) } +// Backing implementation of --copy-etc; just your basic +// recursive copy algorithm. Parent directories are +// created as necessary +fn copy_unmanaged_etc( + sepolicy: &ostree::SePolicy, + src: &Dir, + dest: &Dir, + path: &mut Utf8PathBuf, +) -> Result { + let mut r = 0u64; + for ent in src.read_dir(&path)? { + let ent = ent?; + let name = ent.file_name(); + let name = if let Some(name) = name.to_str() { + name + } else { + anyhow::bail!("Non-UTF8 name: {name:?}"); + }; + let meta = ent.metadata()?; + path.push(Utf8Path::new(name)); + r += 1; + if meta.is_dir() { + if let Some(parent) = path.parent() { + dest.create_dir_all(parent) + .with_context(|| format!("Creating {parent}"))?; + } + if !dest.try_exists(&path)? { + let mut db = DirBuilder::new(); + db.mode(meta.mode()); + let label = Labeler::new(sepolicy, path, meta.mode())?; + dest.create_dir_with(&path, &db) + .with_context(|| format!("Creating {path:?}"))?; + drop(label); + } + r += copy_unmanaged_etc(sepolicy, src, dest, path)?; + } else { + dest.remove_file_optional(&path)?; + let label = Labeler::new(sepolicy, path, meta.mode())?; + if meta.is_symlink() { + let link_target = cap_primitives::fs::read_link_contents( + &src.as_filelike_view(), + path.as_std_path(), + ) + .context("Reading symlink")?; + cap_primitives::fs::symlink_contents(link_target, &dest.as_filelike_view(), &path) + .with_context(|| format!("Writing symlink {path:?}"))?; + } else { + src.copy(&path, dest, &path) + .with_context(|| format!("Copying {path:?}"))?; + } + drop(label); + } + assert!(path.pop()); + } + Ok(r) +} + async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> { if state.override_disable_selinux { rootfs.kargs.push("selinux=0".to_string()); @@ -1071,16 +1163,41 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re tracing::debug!("boot uuid={boot_uuid}"); // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging. - { - let aleph = initialize_ostree_root_from_self(state, rootfs).await?; - rootfs - .rootfs_fd - .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { - serde_json::to_writer(f, &aleph)?; - anyhow::Ok(()) - }) - .context("Writing aleph version")?; - } + let deployresult = initialize_ostree_root_from_self(state, rootfs).await?; + rootfs + .rootfs_fd + .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { + serde_json::to_writer(f, &deployresult.aleph)?; + anyhow::Ok(()) + }) + .context("Writing aleph version")?; + let sepolicy = + ostree::SePolicy::new_at(deployresult.deployment.as_raw_fd(), gio::Cancellable::NONE)?; + + // Copy unmanaged configuration + let target_etc = deployresult + .deployment + .open_dir("etc") + .context("Opening deployment /etc")?; + let copy_etc = state + .config_opts + .copy_etc + .iter() + .flatten() + .cloned() + .collect::>(); + tokio::task::spawn_blocking(move || { + for src in copy_etc { + println!("Injecting configuration from {src}"); + let src = Dir::open_ambient_dir(&src, cap_std::ambient_authority()) + .with_context(|| format!("Opening {src}"))?; + let mut pb = ".".into(); + let n = copy_unmanaged_etc(&sepolicy, &src, &target_etc, &mut pb)?; + tracing::debug!("Copied config files: {n}"); + } + anyhow::Ok(()) + }) + .await??; crate::bootloader::install_via_bootupd(&rootfs.device, &rootfs.rootfs, &state.config_opts)?; tracing::debug!("Installed bootloader"); @@ -1092,6 +1209,8 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re .args(["+i", "."]) .run()?; + drop(deployresult); + // Finalize mounted filesystems if !rootfs.is_alongside { let bootfs = rootfs.rootfs.join("boot"); @@ -1369,11 +1488,78 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu Ok(()) } -#[test] -fn install_opts_serializable() { - let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ - "device": "/dev/vda" - })) - .unwrap(); - assert_eq!(c.block_opts.device, "/dev/vda"); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_opts_serializable() { + let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ + "device": "/dev/vda" + })) + .unwrap(); + assert_eq!(c.block_opts.device, "/dev/vda"); + } + + #[test] + fn test_copy_etc() -> Result<()> { + use std::path::PathBuf; + fn impl_count(d: &Dir, path: &mut PathBuf) -> Result { + let mut c = 0u64; + for ent in d.read_dir(&path)? { + let ent = ent?; + path.push(ent.file_name()); + c += 1; + if ent.file_type()?.is_dir() { + c += impl_count(d, path)?; + } + path.pop(); + } + return Ok(c); + } + fn count(d: &Dir) -> Result { + let mut p = PathBuf::from("."); + impl_count(d, &mut p) + } + + use cap_std_ext::cap_tempfile::TempDir; + let tmproot = TempDir::new(cap_std::ambient_authority())?; + let src_etc = TempDir::new(cap_std::ambient_authority())?; + + let init_tmproot = || -> Result<()> { + tmproot.write("foo.conf", "somefoo")?; + tmproot.symlink("foo.conf", "foo-link.conf")?; + tmproot.create_dir_all("systemd/system")?; + tmproot.write("systemd/system/foo.service", "[fooservice]")?; + tmproot.write("systemd/system/other.service", "[otherservice]")?; + Ok(()) + }; + + let mut pb = ".".into(); + let sepolicy = &ostree::SePolicy::new_at(tmproot.as_raw_fd(), gio::Cancellable::NONE)?; + // First, a no-op + copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 0); + + init_tmproot()?; + + // Another no-op but with data in dest already + copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 6); + + src_etc.write("injected.conf", "injected")?; + copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 7); + + src_etc.create_dir_all("systemd/system")?; + src_etc.write("systemd/system/foo.service", "[overwrittenfoo]")?; + copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 7); + assert_eq!( + tmproot.read_to_string("systemd/system/foo.service")?, + "[overwrittenfoo]" + ); + + Ok(()) + } } diff --git a/lib/src/lsm.rs b/lib/src/lsm.rs index af93985e..c0b2b187 100644 --- a/lib/src/lsm.rs +++ b/lib/src/lsm.rs @@ -161,6 +161,33 @@ pub(crate) fn lsm_label(target: &Utf8Path, as_path: &Utf8Path, recurse: bool) -> .run() } +#[cfg(feature = "install")] +pub(crate) struct Labeler<'a> { + _sepolicy: &'a ostree::SePolicy, +} + +#[cfg(feature = "install")] +impl<'a> Labeler<'a> { + pub(crate) fn new( + sepolicy: &'a ostree::SePolicy, + path: &'_ Utf8Path, + mode: u32, + ) -> Result { + sepolicy.setfscreatecon(path.as_str(), mode)?; + Ok(Self { + _sepolicy: sepolicy, + }) + } +} + +#[cfg(feature = "install")] +impl<'a> Drop for Labeler<'a> { + fn drop(&mut self) { + // TODO: add better bindings for only calling this if we did find a label + ostree::SePolicy::fscreatecon_cleanup() + } +} + #[cfg(feature = "install")] pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool { let v = xattrs.data_as_bytes(); diff --git a/tests/kolainst/install b/tests/kolainst/install index 121dc2af..08b09520 100755 --- a/tests/kolainst/install +++ b/tests/kolainst/install @@ -20,7 +20,7 @@ cd $(mktemp -d) case "${AUTOPKGTEST_REBOOT_MARK:-}" in "") mkdir -p ~/.config/containers - cp -a /etc/ostree/auth.json ~/.config/containers + if test -f /etc/ostree/auth.json; then cp -a /etc/ostree/auth.json ~/.config/containers; fi mkdir -p usr/{lib,bin} cp -a /usr/lib/bootc usr/lib cp -a /usr/bin/bootc usr/bin @@ -29,8 +29,13 @@ case "${AUTOPKGTEST_REBOOT_MARK:-}" in COPY usr usr EOF podman build -t localhost/testimage . - podman run --rm -ti --privileged --pid=host --env RUST_LOG=error,bootc_lib::install=debug \ - localhost/testimage bootc install to-disk --skip-fetch-check --karg=foo=bar ${DEV} + mkdir -p injected-config/systemd/system/ + cat > injected-config/systemd/system/injected.service << 'EOF' +[Service] +ExecStart=echo injected +EOF + podman run --rm -ti --privileged --pid=host -v ./injected-config:/config --env RUST_LOG=error,bootc_lib::install=debug \ + localhost/testimage bootc install to-disk --copy-etc /config --skip-fetch-check --karg=foo=bar ${DEV} # In theory we could e.g. wipe the bootloader setup on the primary disk, then reboot; # but for now let's just sanity test that the install command executes. lsblk ${DEV} @@ -39,6 +44,10 @@ EOF grep localtestkarg=somevalue /var/mnt/loader/entries/*.conf grep -Ee '^linux /boot/ostree' /var/mnt/loader/entries/*.conf umount /var/mnt + mount /dev/vda4 /var/mnt + deploydir=$(echo /var/mnt/ostree/deploy/default/deploy/*.0) + diff $deploydir/etc/systemd/system/injected.service injected-config/systemd/system/injected.service + umount /var/mnt echo "ok install" # Now test install to-filesystem