diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f5e0505..137d947dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,10 +69,17 @@ jobs: uses: actions/checkout@v4 - name: Free up disk space on runner run: sudo ./ci/clean-gha-runner.sh + - name: Enable fsverity for / + run: sudo tune2fs -O verity $(findmnt -vno SOURCE /) + - name: Install utils + run: sudo apt -y install fsverity - name: Integration tests run: | set -xeu + # Build images to test; TODO investigate doing single container builds + # via GHA and pushing to a temporary registry to share among workflows? sudo podman build -t localhost/bootc -f hack/Containerfile . + sudo podman build -t localhost/bootc-fsverity -f ci/Containerfile.install-fsverity export CARGO_INCREMENTAL=0 # because we aren't caching the test runner bits cargo build --release -p tests-integration df -h / @@ -84,8 +91,9 @@ jobs: -v /run/dbus:/run/dbus -v /run/systemd:/run/systemd localhost/bootc /src/ostree-ext/ci/priv-integration.sh # Nondestructive but privileged tests sudo bootc-integration-tests host-privileged localhost/bootc - # Finally the install-alongside suite + # Install tests sudo bootc-integration-tests install-alongside localhost/bootc + sudo bootc-integration-tests install-fsverity localhost/bootc-fsverity docs: if: ${{ contains(github.event.pull_request.labels.*.name, 'documentation') }} runs-on: ubuntu-latest diff --git a/ci/Containerfile.install-fsverity b/ci/Containerfile.install-fsverity new file mode 100644 index 000000000..a47c2964f --- /dev/null +++ b/ci/Containerfile.install-fsverity @@ -0,0 +1,14 @@ +# Enable fsverity at install time +FROM localhost/bootc +RUN < /usr/lib/ostree/prepare-root.conf < /usr/lib/bootc/install/90-ext4.toml < Result<()> { Ok(()) } }, + InternalsOpts::Fsck => { + let storage = get_storage().await?; + let r = crate::fsck::fsck(&storage).await?; + match r.errors.as_slice() { + [] => {} + errs => { + for err in errs { + eprintln!("error: {err}"); + } + anyhow::bail!("fsck found errors"); + } + } + Ok(()) + } InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root), InternalsOpts::PrintJsonSchema { of } => { let schema = match of { diff --git a/lib/src/fsck.rs b/lib/src/fsck.rs new file mode 100644 index 000000000..c16e7b3c2 --- /dev/null +++ b/lib/src/fsck.rs @@ -0,0 +1,157 @@ +//! # Write deployments merging image with configmap +//! +//! Create a merged filesystem tree with the image and mounted configmaps. + +use std::os::fd::AsFd; +use std::str::FromStr as _; + +use anyhow::Ok; +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use fn_error_context::context; +use ostree_ext::keyfileext::KeyFileExt; +use ostree_ext::ostree; +use ostree_ext::ostree_prepareroot::Tristate; +use serde::{Deserialize, Serialize}; + +use crate::store::{self, Storage}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum VerityState { + Enabled, + Disabled, + Inconsistent((u64, u64)), +} + +#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub(crate) struct FsckResult { + pub(crate) notices: Vec, + pub(crate) errors: Vec, + pub(crate) verity: Option, +} + +type Errors = Vec; + +/// Check the fsverity state of all regular files in this object directory. +#[context("Computing verity state")] +fn verity_state_of_objects( + d: &Dir, + prefix: &str, + expected: Tristate, +) -> Result<(u64, u64, Errors)> { + let mut enabled = 0; + let mut disabled = 0; + let mut errs = Errors::default(); + for ent in d.entries()? { + let ent = ent?; + if !ent.file_type()?.is_file() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + let Some("file") = name.extension() else { + continue; + }; + let f = d + .open(&name) + .with_context(|| format!("Failed to open {name}"))?; + let r: Option = + composefs::fsverity::ioctl::fs_ioc_measure_verity(f.as_fd())?; + drop(f); + if r.is_some() { + enabled += 1; + } else { + disabled += 1; + if expected == Tristate::Enabled { + errs.push(format!( + "fsverity is not enabled for object: {prefix}{name}" + )); + } + } + } + Ok((enabled, disabled, errs)) +} + +async fn verity_state_of_all_objects( + repo: &ostree::Repo, + expected: Tristate, +) -> Result<(u64, u64, Errors)> { + const MAX_CONCURRENT: usize = 3; + + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + + let mut joinset = tokio::task::JoinSet::new(); + let mut results = Vec::new(); + + for ent in repodir.read_dir("objects")? { + while joinset.len() >= MAX_CONCURRENT { + results.push(joinset.join_next().await.unwrap()??); + } + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + + let objdir = ent.open_dir()?; + let expected = expected.clone(); + joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected)); + } + + while let Some(output) = joinset.join_next().await { + results.push(output??); + } + let r = results + .into_iter() + .fold((0, 0, Errors::default()), |mut acc, v| { + acc.0 += v.0; + acc.1 += v.1; + acc.2.extend(v.2); + acc + }); + Ok(r) +} + +pub(crate) async fn fsck(storage: &Storage) -> Result { + let mut r = FsckResult::default(); + + let repo_config = storage.repo().config(); + let expected_verity = { + let (k, v) = store::REPO_VERITY_CONFIG.split_once('.').unwrap(); + repo_config + .optional_string(k, v)? + .map(|v| Tristate::from_str(&v)) + .transpose()? + .unwrap_or_default() + }; + tracing::debug!("expected_verity={expected_verity:?}"); + + let verity_found_state = + verity_state_of_all_objects(&storage.repo(), expected_verity.clone()).await?; + r.errors.extend(verity_found_state.2); + r.verity = match (verity_found_state.0, verity_found_state.1) { + (0, 0) => None, + (_, 0) => Some(VerityState::Enabled), + (0, _) => Some(VerityState::Disabled), + (enabled, disabled) => Some(VerityState::Inconsistent((enabled, disabled))), + }; + if let Some(VerityState::Inconsistent((enabled, disabled))) = r.verity { + let inconsistent = + format!("Inconsistent fsverity state (enabled: {enabled} disabled: {disabled})"); + match expected_verity { + Tristate::Disabled | Tristate::Maybe => r.notices.push(inconsistent), + Tristate::Enabled => r.errors.push(inconsistent), + } + } + Ok(r) +} diff --git a/lib/src/install.rs b/lib/src/install.rs index 7b8982961..3418d5a7a 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -40,6 +40,7 @@ use fn_error_context::context; use ostree::gio; use ostree_ext::oci_spec; use ostree_ext::ostree; +use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; use ostree_ext::prelude::Cast; use ostree_ext::sysroot::SysrootLock; use ostree_ext::{container as ostree_container, ostree_prepareroot}; @@ -77,6 +78,15 @@ const SELINUXFS: &str = "/sys/fs/selinux"; const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); +const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ + // Default to avoiding grub2-mkconfig etc. + ("sysroot.bootloader", "none"), + // Always flip this one on because we need to support alongside installs + // to systems without a separate boot partition. + ("sysroot.bootprefix", "true"), + ("sysroot.readonly", "true"), +]; + /// Kernel argument used to specify we want the rootfs mounted read-write by default const RW_KARG: &str = "rw"; @@ -638,14 +648,22 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result crate::lsm::ensure_dir_labeled(rootfs_dir, "boot", None, 0o755.into(), sepolicy)?; } - for (k, v) in [ - // Default to avoiding grub2-mkconfig etc. - ("sysroot.bootloader", "none"), - // Always flip this one on because we need to support alongside installs - // to systems without a separate boot partition. - ("sysroot.bootprefix", "true"), - ("sysroot.readonly", "true"), - ] { + let prepare_root_composefs = state + .prepareroot_config + .get("composefs.enabled") + .map(|v| ComposefsState::from_str(&v)) + .transpose()? + .unwrap_or(ComposefsState::default()); + let fsverity_ostree_key = crate::store::REPO_VERITY_CONFIG; + let fsverity_ostree_opt = match prepare_root_composefs { + ComposefsState::Signed | ComposefsState::Verity => Some((fsverity_ostree_key, "yes")), + ComposefsState::Tristate(Tristate::Disabled) => None, + ComposefsState::Tristate(_) => Some((fsverity_ostree_key, "maybe")), + }; + for (k, v) in DEFAULT_REPO_CONFIG + .iter() + .chain(fsverity_ostree_opt.as_ref()) + { Command::new("ostree") .args(["config", "--repo", "ostree/repo", "set", k, v]) .cwd_dir(rootfs_dir.try_clone()?) diff --git a/lib/src/install/baseline.rs b/lib/src/install/baseline.rs index e8f02b0b2..ec14e3bf0 100644 --- a/lib/src/install/baseline.rs +++ b/lib/src/install/baseline.rs @@ -162,13 +162,12 @@ pub(crate) fn install_create_rootfs( state: &State, opts: InstallBlockDeviceOpts, ) -> Result { + let install_config = state.install_config.as_ref(); let luks_name = "root"; // Ensure we have a root filesystem upfront let root_filesystem = opts .filesystem - .or(state - .install_config - .as_ref() + .or(install_config .and_then(|c| c.filesystem_root()) .and_then(|r| r.fstype)) .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; @@ -207,7 +206,7 @@ pub(crate) fn install_create_rootfs( } // Use the install configuration to find the block setup, if we have one - let block_setup = if let Some(config) = state.install_config.as_ref() { + let block_setup = if let Some(config) = install_config { config.get_block_setup(opts.block_setup.as_ref().copied())? } else if opts.filesystem.is_some() { // Otherwise, if a filesystem is specified then we default to whatever was @@ -386,8 +385,20 @@ pub(crate) fn install_create_rootfs( None }; + // Unconditionally enable fsverity for ext4 + let mkfs_options = match root_filesystem { + Filesystem::Ext4 => ["-O", "verity"].as_slice(), + _ => [].as_slice(), + }; + // Initialize rootfs - let root_uuid = mkfs(&rootdev, root_filesystem, "root", opts.wipe, [])?; + let root_uuid = mkfs( + &rootdev, + root_filesystem, + "root", + opts.wipe, + mkfs_options.iter().copied(), + )?; let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}")); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 85ad8f024..d05e9f968 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -7,6 +7,7 @@ mod boundimage; pub mod cli; pub(crate) mod deploy; +pub(crate) mod fsck; pub(crate) mod generator; mod glyph; mod image; diff --git a/lib/src/store/mod.rs b/lib/src/store/mod.rs index 9ebe25dfe..ec18e287c 100644 --- a/lib/src/store/mod.rs +++ b/lib/src/store/mod.rs @@ -20,6 +20,8 @@ mod ostree_container; /// The path to the bootc root directory, relative to the physical /// system root pub(crate) const BOOTC_ROOT: &str = "ostree/bootc"; +/// The ostree repo config option to enable fsverity +pub(crate) const REPO_VERITY_CONFIG: &str = "ex-integrity.fsverity"; pub(crate) struct Storage { pub sysroot: SysrootLock, diff --git a/ostree-ext/src/ostree_prepareroot.rs b/ostree-ext/src/ostree_prepareroot.rs index b9fd5aeb9..08cde1113 100644 --- a/ostree-ext/src/ostree_prepareroot.rs +++ b/ostree-ext/src/ostree_prepareroot.rs @@ -9,6 +9,7 @@ use std::str::FromStr; use anyhow::{Context, Result}; use camino::Utf8Path; use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; use glib::Cast; use ocidir::cap_std::fs::Dir; use ostree::prelude::FileExt; @@ -20,7 +21,8 @@ use crate::utils::ResultExt; pub(crate) const CONF_PATH: &str = "ostree/prepare-root.conf"; -pub(crate) fn load_config(root: &ostree::RepoFile) -> Result> { +/// Load the ostree prepare-root config from the given ostree repository. +pub fn load_config(root: &ostree::RepoFile) -> Result> { let cancellable = gio::Cancellable::NONE; let kf = glib::KeyFile::new(); for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) { @@ -65,7 +67,7 @@ pub fn require_config_from_root(root: &Dir) -> Result { /// Query whether the target root has the `root.transient` key /// which sets up a transient overlayfs. -pub(crate) fn overlayfs_root_enabled(root: &ostree::RepoFile) -> Result { +pub fn overlayfs_root_enabled(root: &ostree::RepoFile) -> Result { if let Some(config) = load_config(root)? { overlayfs_enabled_in_config(&config) } else { @@ -73,10 +75,14 @@ pub(crate) fn overlayfs_root_enabled(root: &ostree::RepoFile) -> Result { } } -#[derive(Debug, PartialEq, Eq)] -enum Tristate { +/// An option which can be enabled, disabled, or possibly enabled. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Tristate { + /// Enabled Enabled, + /// Disabled Disabled, + /// Maybe Maybe, } @@ -110,9 +116,14 @@ impl Tristate { } } +/// The state of a composefs for ostree #[derive(Debug, PartialEq, Eq)] -enum ComposefsState { +pub enum ComposefsState { + /// The composefs must be signed and use fsverity Signed, + /// The composefs must use fsverity + Verity, + /// The composefs may or may not be enabled. Tristate(Tristate), } @@ -125,9 +136,11 @@ impl Default for ComposefsState { impl FromStr for ComposefsState { type Err = anyhow::Error; + #[context("Parsing composefs.enabled value {s}")] fn from_str(s: &str) -> Result { let r = match s { "signed" => Self::Signed, + "verity" => Self::Verity, o => Self::Tristate(Tristate::from_str(o)?), }; Ok(r) @@ -137,10 +150,15 @@ impl FromStr for ComposefsState { impl ComposefsState { pub(crate) fn maybe_enabled(&self) -> bool { match self { - ComposefsState::Signed => true, + ComposefsState::Signed | ComposefsState::Verity => true, ComposefsState::Tristate(t) => t.maybe_enabled(), } } + + /// This configuration requires fsverity on the target filesystem. + pub fn requires_fsverity(&self) -> bool { + matches!(self, ComposefsState::Signed | ComposefsState::Verity) + } } /// Query whether the config uses an overlayfs model (composefs or plain overlayfs).