Skip to content

Commit

Permalink
install: Honor composefs.enabled=verity
Browse files Browse the repository at this point in the history
Key off the ostree prepare-root config to require fsverity
on all objects.

As part of this:

- Add a dependency on composefs-rs just for the fsverity querying
  APIs, and as prep for further integration.
- Add `bootc internals fsck`, which verifies the expected
  fsverity state.

Signed-off-by: Colin Walters <[email protected]>
  • Loading branch information
cgwalters committed Feb 3, 2025
1 parent cca41fb commit cfad476
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 20 deletions.
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions ci/Containerfile.install-fsverity
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Enable fsverity at install time
FROM localhost/bootc
RUN <<EORUN
set -xeuo pipefail
cat > /usr/lib/ostree/prepare-root.conf <<EOF
[composefs]
enabled = verity
EOF
cat > /usr/lib/bootc/install/90-ext4.toml <<EOF
[install.filesystem.root]
type = "ext4"
EOF
bootc container lint
EORUN
16 changes: 16 additions & 0 deletions lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,8 @@ pub(crate) enum InternalsOpts {
},
#[clap(subcommand)]
Fsverity(FsverityOpts),
/// Perform consistency checking.
Fsck,
/// Perform cleanup actions
Cleanup,
/// Proxy frontend for the `ostree-ext` CLI.
Expand Down Expand Up @@ -1149,6 +1151,20 @@ async fn run_from_opt(opt: Opt) -> 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 {
Expand Down
157 changes: 157 additions & 0 deletions lib/src/fsck.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub(crate) errors: Vec<String>,
pub(crate) verity: Option<VerityState>,
}

type Errors = Vec<String>;

/// 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::Sha256HashValue> =
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<FsckResult> {
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)
}
34 changes: 26 additions & 8 deletions lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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()?)
Expand Down
21 changes: 16 additions & 5 deletions lib/src/install/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,12 @@ pub(crate) fn install_create_rootfs(
state: &State,
opts: InstallBlockDeviceOpts,
) -> Result<RootSetup> {
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"))?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"));
Expand Down
1 change: 1 addition & 0 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions lib/src/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit cfad476

Please sign in to comment.