Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ authors = ["Colin Walters <[email protected]>"]
edition = "2021"
rust-version = "1.84.1"
homepage = "https://github.com/coreos/bootupd"
build = "build.rs"

include = ["src", "LICENSE", "Makefile", "systemd"]

Expand All @@ -15,6 +16,10 @@ include = ["src", "LICENSE", "Makefile", "systemd"]
platforms = ["*-unknown-linux-gnu"]
tier = "2"

[features]
default = []
systemd-boot = []

[[bin]]
name = "bootupd"
path = "src/main.rs"
Expand All @@ -27,7 +32,14 @@ bootc-internal-utils = "0.0.0"
cap-std-ext = "4.0.6"
camino = "1.1.11"
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5", default-features = false, features = ["cargo", "derive", "std", "help", "usage", "suggestions"] }
clap = { version = "4.5", default-features = false, features = [
"cargo",
"derive",
"std",
"help",
"usage",
"suggestions",
] }
env_logger = "0.11"
fail = { version = "0.5", features = ["failpoints"] }
fn-error-context = "0.2.1"
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ that's for tools like `grubby` and `ostree`.
bootupd supports updating GRUB and shim for UEFI firmware on x86_64, aarch64,
and riscv64, and GRUB for BIOS firmware on x86_64 and ppc64le.

bootupd only supports installing the systemd-boot shim currently, though may be
updated to also handle updates in future. systemd-boot support just proxies
to the relevant `bootctl` commands.

The project is used in Bootable Containers and ostree/rpm-ostree based systems:
- [`bootc install`](https://github.com/containers/bootc/#using-bootc-install)
- [Fedora CoreOS](https://docs.fedoraproject.org/en-US/fedora-coreos/bootloader-updates/)
Expand Down Expand Up @@ -78,7 +82,7 @@ care of GRUB and shim. See discussion in [this issue](https://github.com/coreos
### systemd bootctl

[systemd bootctl](https://man7.org/linux/man-pages/man1/bootctl.1.html) can update itself;
this project would probably just proxy that if we detect systemd-boot is in use.
this project just proxies that if we detect systemd-boot is present.

## Other goals

Expand Down Expand Up @@ -151,4 +155,3 @@ bootupd now uses `systemd-run` instead to guarantee the following:
- If we want a non-CLI API (whether that's DBus or Cap'n Proto or varlink or
something else), we will create an independent daemon with a stable API for
this specific need.

9 changes: 9 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
fn main() {
if std::env::var("CARGO_FEATURE_SYSTEMD_BOOT").is_ok() {
if let Ok(arch) = std::env::var("CARGO_CFG_TARGET_ARCH") {
if arch.starts_with("riscv") {
panic!("The systemd-boot feature is not supported on RISC-V.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, this whole build.rs seems like it could use a comment for why; I'm sure some RISC-V systems aren't UEFI but it looks like at least some are working on it e.g. https://archive.fosdem.org/2021/schedule/event/firmware_uor/#:~:text=Online%20%2F%206%20%26%207%20February%202021,EDK2%20UEFI%20on%20RISC%2DV

Do we actually fail to build on RISC-V in this case?

}
}
}
}
1 change: 1 addition & 0 deletions src/bios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ impl Component for Bios {
dest_root: &str,
device: &str,
_update_firmware: bool,
_bootloader: &crate::bootupd::Bootloader,
) -> Result<InstalledContent> {
let Some(meta) = get_component_update(src_root, self)? else {
anyhow::bail!("No update metadata for component {} found", self.name());
Expand Down
87 changes: 81 additions & 6 deletions src/bootupd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Bootloader {
Grub2,
#[cfg(feature = "systemd-boot")]
SystemdBoot,
}

pub(crate) enum ConfigMode {
None,
Static,
Expand Down Expand Up @@ -81,6 +88,8 @@ pub(crate) fn install(
anyhow::bail!("No components specified");
}

let bootloader = select_bootloader(&source_root);

let mut state = SavedState::default();
let mut installed_efi_vendor = None;
for &component in target_components.iter() {
Expand All @@ -93,20 +102,51 @@ pub(crate) fn install(
continue;
}

#[cfg(feature = "systemd-boot")]
if bootloader == Bootloader::SystemdBoot && component.name() == "BIOS" {
log::warn!("Skip installing BIOS component when using systemd-boot");
continue;
}

let update_firmware = match bootloader {
Bootloader::Grub2 => update_firmware,
#[cfg(feature = "systemd-boot")]
Bootloader::SystemdBoot => false,
};

let meta = component
.install(&source_root, dest_root, device, update_firmware)
.install(
&source_root,
dest_root,
device,
update_firmware,
&bootloader,
)
.with_context(|| format!("installing component {}", component.name()))?;
log::info!("Installed {} {}", component.name(), meta.meta.version);
state.installed.insert(component.name().into(), meta);
// Yes this is a hack...the Component thing just turns out to be too generic.
if let Some(vendor) = component.get_efi_vendor(&source_root)? {
assert!(installed_efi_vendor.is_none());
installed_efi_vendor = Some(vendor);

match bootloader {
Bootloader::Grub2 => {
// Yes this is a hack...the Component thing just turns out to be too generic.
if let Some(vendor) = component.get_efi_vendor(&source_root)? {
assert!(installed_efi_vendor.is_none());
installed_efi_vendor = Some(vendor);
}
}
#[cfg(feature = "systemd-boot")]
_ => {}
}
}
let sysroot = &openat::Dir::open(dest_root)?;

match configs.enabled_with_uuid() {
// If systemd-boot is enabled, do not run grubconfigs::install
let configs_with_uuid = match bootloader {
Bootloader::Grub2 => configs.enabled_with_uuid(),
#[cfg(feature = "systemd-boot")]
_ => None,
};
match configs_with_uuid {
Some(uuid) => {
let meta = get_static_config_meta()?;
state.static_configs = Some(meta);
Expand Down Expand Up @@ -715,6 +755,41 @@ fn strip_grub_config_file(
Ok(())
}

/// Determine whether the necessary bootloader files are present for GRUB.
fn has_grub(source_root: &openat::Dir) -> bool {
source_root.open_file("usr/sbin/grub2-install").is_ok()
}

/// Determine whether the necessary bootloader files are present for systemd-boot.
#[cfg(feature = "systemd-boot")]
fn has_systemd_boot(source_root: &openat::Dir) -> bool {
source_root.open_file(efi::SYSTEMD_BOOT_EFI).is_ok()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor but doing a stat() instead of open() is slightly more efficient i.e. use source_root.metadata()

}

/// Select the bootloader based on available binaries and feature flags.
fn select_bootloader(source_root: &openat::Dir) -> Bootloader {
#[cfg(not(feature = "systemd-boot"))]
{
if has_grub(source_root) {
Bootloader::Grub2
} else {
log::warn!("No bootloader binaries found, defaulting to Grub2");
Bootloader::Grub2
}
}
#[cfg(feature = "systemd-boot")]
{
if has_grub(source_root) {
Bootloader::Grub2
} else if has_systemd_boot(source_root) {
Bootloader::SystemdBoot
} else {
log::warn!("No bootloader binaries found, defaulting to Grub2");
Bootloader::Grub2
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub(crate) trait Component {
dest_root: &str,
device: &str,
update_firmware: bool,
bootloader: &crate::bootupd::Bootloader,
) -> Result<InstalledContent>;

/// Implementation of `bootupd generate-update-metadata` for a given component.
Expand Down
39 changes: 31 additions & 8 deletions src/efi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use rustix::fd::BorrowedFd;
use walkdir::WalkDir;
use widestring::U16CString;

use crate::bootupd::RootContext;
use crate::bootupd::{Bootloader, RootContext};
use crate::freezethaw::fsfreeze_thaw_cycle;
use crate::model::*;
use crate::ostreeutil;
Expand Down Expand Up @@ -50,6 +50,10 @@ pub(crate) const SHIM: &str = "shimriscv64.efi";
/// Systemd boot loader info EFI variable names
const LOADER_INFO_VAR_STR: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
const STUB_INFO_VAR_STR: &str = "StubInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
#[cfg(all(feature = "systemd-boot", target_arch = "aarch64"))]
pub(crate) const SYSTEMD_BOOT_EFI: &str = "usr/lib/systemd/boot/efi/systemd-bootaarch64.efi";
#[cfg(all(feature = "systemd-boot", target_arch = "x86_64"))]
pub(crate) const SYSTEMD_BOOT_EFI: &str = "usr/lib/systemd/boot/efi/systemd-bootx64.efi";

/// Return `true` if the system is booted via EFI
pub(crate) fn is_efi_booted() -> Result<bool> {
Expand Down Expand Up @@ -342,14 +346,8 @@ impl Component for Efi {
dest_root: &str,
device: &str,
update_firmware: bool,
bootloader: &Bootloader,
) -> Result<InstalledContent> {
let Some(meta) = get_component_update(src_root, self)? else {
anyhow::bail!("No update metadata for component {} found", self.name());
};
log::debug!("Found metadata {}", meta.version);
let srcdir_name = component_updatedirname(self);
let ft = crate::filetree::FileTree::new_from_dir(&src_root.sub_dir(&srcdir_name)?)?;

// Let's attempt to use an already mounted ESP at the target
// dest_root if one is already mounted there in a known ESP location.
let destpath = if let Some(destdir) = self.get_mounted_esp(Path::new(dest_root))? {
Expand All @@ -365,6 +363,31 @@ impl Component for Efi {
self.mount_esp_device(Path::new(dest_root), Path::new(&esp_device))?
};

match bootloader {
#[cfg(feature = "systemd-boot")]
Bootloader::SystemdBoot => {
log::info!("Installing systemd-boot via bootctl");
let esp_dir = openat::Dir::open(&destpath)?;
crate::systemdbootconfigs::install(src_root, &esp_dir)?;
return Ok(InstalledContent {
meta: ContentMetadata {
timestamp: Utc::now(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This timestamp should be sourced from the bootctl binary

version: "systemd-boot".to_string(),
},
filetree: None,
adopted_from: None,
});
}
_ => {}
}

let Some(meta) = get_component_update(src_root, self)? else {
anyhow::bail!("No update metadata for component {} found", self.name());
};
log::debug!("Found metadata {}", meta.version);
let srcdir_name = component_updatedirname(self);
let ft = crate::filetree::FileTree::new_from_dir(&src_root.sub_dir(&srcdir_name)?)?;

let destd = &openat::Dir::open(&destpath)
.with_context(|| format!("opening dest dir {}", destpath.display()))?;
validate_esp_fstype(destd)?;
Expand Down
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ mod packagesystem;
mod sha512string;
mod util;

#[cfg(feature = "systemd-boot")]
mod systemdbootconfigs;

use clap::crate_name;

/// Binary entrypoint, for both daemon and client logic.
Expand Down
61 changes: 61 additions & 0 deletions src/systemdbootconfigs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use std::path::Path;

use anyhow::{Context, Result};
use fn_error_context::context;

const CONFIG_DIR: &str = "usr/lib/bootupd/systemd-boot";

/// Install files required for systemd-boot
/// This mostly proxies the bootctl install command
#[context("Installing systemd-boot")]
pub(crate) fn install(src_root: &openat::Dir, esp_path: &openat::Dir) -> Result<()> {
let esp_path_buf = esp_path.recover_path().context("ESP path is not valid")?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using recover_path() a trick is to pass the fd to the child via its working directory. Again in cap-std-ext we have https://docs.rs/cap-std-ext/latest/cap_std_ext/cmdext/trait.CapStdExtCommandExt.html#tymethod.cwd_dir

let esp_path_str = esp_path_buf
.to_str()
.context("ESP path is not valid UTF-8")?;
let status = std::process::Command::new("bootctl")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't be able to use bootctl isntall by default here as that's skipping shim entirely.

Copy link
Author

@p5 p5 Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, understood.

Would you suggest doing something like this (but pointing to the right paths)?

efibootmgr --disk /dev/sda --part 1 --create --label "Linux Secure Boot" --loader '\EFI\Systemd\shimx64.efi' --unicode '\EFI\Systemd\systemd-bootx64.efi'

Will need to figure out a way to identify the shim without reintroducing the RPM code.
Though with this approach, we can probably still use bootctl install, but optionally followed by copying the shim and running efibootmgr if the shim is detected.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! I did not know that we could pass arguments to shim! If this works then this is great. We should probably install the systemd-boot binary under the os path: /EFI/<os|fedora|arch>/systemd-bootx64.efi to not have a "global" instance of it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't be able to use bootctl isntall by default here as that's skipping shim entirely.

For sealed images we do want to skip shim though right?

Will need to figure out a way to identify the shim without reintroducing the RPM code.

I think it should be the same as detecting grub ideally. I'm not sure we care about grub-but-not-shim (hopefully?) but we do care about the matrix of:

  • shim+grub
  • shim+systemd-boot
  • systemd-boot

So...I think we can reasonably do this by detecting if we have shim*.efi in the image.

Would you suggest doing something like [https://github.com/systemd/systemd/issues/27234#issuecomment-2424826157] (but pointing to the right paths)?

Hmm AFAIK efibootmgr is primarily for manipulating the EFI firmware, which is distinct from the files on disk. We already have code which...currently hardcodes targeting shim, which would definitely have to change for sealed images.

.args(["install", "--esp-path", esp_path_str])
.status()
.context("Failed to execute bootctl")?;

if !status.success() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use our helper trait .run_with_cmd_context() or so

anyhow::bail!(
"bootctl install failed with status code {}",
status.code().unwrap_or(-1)
);
}

// If loader.conf is present in the bootupd configuration, replace the original config with it
let configdir_path = Path::new(CONFIG_DIR);
if let Err(e) = try_copy_loader_conf(src_root, configdir_path, esp_path_str) {
log::debug!("Optional loader.conf copy skipped: {}", e);
}

Ok(())
}

/// Try to copy loader.conf from configdir to ESP, returns error if not present or copy fails
fn try_copy_loader_conf(
src_root: &openat::Dir,
configdir_path: &Path,
esp_path_str: &str,
) -> Result<()> {
let configdir = src_root
.sub_dir(configdir_path)
.context(format!("Config directory '{}' not found", CONFIG_DIR))?;
let dst_loader_conf = Path::new(esp_path_str).join("loader/loader.conf");
match configdir.open_file("loader.conf") {
Ok(mut src_file) => {
let mut dst_file = std::fs::File::create(&dst_loader_conf)
.context("Failed to create loader.conf in ESP")?;
std::io::copy(&mut src_file, &mut dst_file)
.context("Failed to copy loader.conf to ESP")?;
log::info!("loader.conf copied to {}", dst_loader_conf.display());
Ok(())
}
Err(e) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only match if this is ENOENT, but propagate other errors.

For cap-std-ext we have https://docs.rs/cap-std-ext/latest/cap_std_ext/dirext/trait.CapStdExtDirExt.html#tymethod.open_optional but we're not yet using cap-std here...

log::debug!("loader.conf not found in configdir, skipping: {}", e);
Err(anyhow::anyhow!(e))
}
}
}