From 1ea9a4f79eef0814d4ca5dc23d5c067ac91df0d7 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 14:04:13 +0200 Subject: [PATCH 1/3] ephemeral: Strip bootconfig before extending initramfs Bootconfig must occupy the end of an initramfs. Ephemeral boots supply their own kernel command line, so remove any existing trailer before appending bcvk units. Assisted-by: AI Signed-off-by: Alexander Larsson --- contrib/packaging/bcvk.spec | 3 ++ crates/kit/src/run_ephemeral.rs | 57 +++++++++++++++++++++++++++++++++ docs/src/installation.md | 1 + 3 files changed, 61 insertions(+) diff --git a/contrib/packaging/bcvk.spec b/contrib/packaging/bcvk.spec index 10f57dc7e..4309db68e 100644 --- a/contrib/packaging/bcvk.spec +++ b/contrib/packaging/bcvk.spec @@ -19,6 +19,9 @@ Requires: podman Requires: qemu-img Requires: qemu-kvm Requires: virtiofsd +# Extraction and initramfs tools are needed for some ephemeral boot images. +Recommends: binutils +Recommends: kernel-tools # libvirt-client is optional but recommended for 'bcvk libvirt' commands Recommends: libvirt-client diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 97d41a6bc..490c90014 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -131,6 +131,9 @@ const IGNITION_SERIAL_NAME: &str = "ignition"; /// Mount path for Ignition config inside the container const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json"; +/// Kernel initramfs magic marker for bootconfig trailer. +const BOOTCONFIG_MAGIC: &[u8; 12] = b"#BOOTCONFIG\n"; + // --------------------------------------------------------------------------- // Journal / output mode types // --------------------------------------------------------------------------- @@ -1231,6 +1234,38 @@ fn check_required_container_binaries() -> Result<()> { Ok(()) } +fn require_binary(binary: &str) -> Result<()> { + which::which(binary).map_err(|_| eyre!("Missing required executable: {binary}"))?; + Ok(()) +} + +fn has_bootconfig_trailer(path: &str) -> Result { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path)?; + if file.metadata()?.len() < BOOTCONFIG_MAGIC.len() as u64 { + return Ok(false); + } + file.seek(SeekFrom::End(-(BOOTCONFIG_MAGIC.len() as i64)))?; + let mut magic = [0u8; BOOTCONFIG_MAGIC.len()]; + file.read_exact(&mut magic)?; + Ok(&magic == BOOTCONFIG_MAGIC) +} + +/// Remove a bootconfig trailer before modifying an initramfs. Without this, +/// appending CPIO would leave the trailer in the middle of the initramfs. +fn remove_bootconfig(initramfs: &str) -> Result<()> { + if !has_bootconfig_trailer(initramfs)? { + return Ok(()); + } + require_binary("bootconfig")?; + Command::new("bootconfig") + .args(["-d", initramfs]) + .run_capture_stderr() + .map_err(|e| eyre!("Removing bootconfig before appending initramfs units: {e}"))?; + Ok(()) +} + /// Check if the container image has Ignition support /// /// Checks for labels indicating Ignition support: @@ -1409,6 +1444,8 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { .map_err(|e| eyre!("Failed to copy initramfs: {e}"))?; } + remove_bootconfig(initramfs_mount)?; + // Append bcvk units to initramfs // This includes: // - /etc overlay and /var ephemeral services (run in initramfs) @@ -2154,6 +2191,26 @@ Options= mod tests { use super::*; + #[test] + fn test_has_bootconfig_trailer() -> Result<()> { + use std::io::Write; + + for (contents, expected) in [ + (b"".as_slice(), false), + (b"short".as_slice(), false), + (b"initrd#BOOTCONFIG\n".as_slice(), true), + (b"initrd#BOOTCONFIG\ncpio".as_slice(), false), + ] { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(contents)?; + assert_eq!( + has_bootconfig_trailer(file.path().to_str().unwrap())?, + expected + ); + } + Ok(()) + } + #[test] fn test_journal_json_to_text() { // _SYSTEMD_UNIT takes priority over SYSLOG_IDENTIFIER, with PID diff --git a/docs/src/installation.md b/docs/src/installation.md index 8b10a3863..a023c152e 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,6 +22,7 @@ For running bcvk: - virtiofsd - Podman - openssh-clients (for libvirt SSH operations) +- kernel-tools (for removing bootconfig before appending to the initramfs) Optional: - libvirt (for persistent VM features) From 3caed6dbdef74d3b9e7313dcfc913460540570a2 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 14:04:29 +0200 Subject: [PATCH 2/3] ephemeral: Uncompress zstd kernels where needed On aarch64 we qemu has to decompress kernels because the kernel itself can't do it. zstd decompression code was added in qemu 11.0, so in older version we use the unzboot tool to decompress it. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/bcvk-qemu/src/lib.rs | 4 +- crates/bcvk-qemu/src/qemu.rs | 92 +++++++++++++++++++++++++++------ crates/kit/src/run_ephemeral.rs | 86 ++++++++++++++++++++++++++++++ docs/src/installation.md | 2 +- 4 files changed, 164 insertions(+), 20 deletions(-) diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 91cbffea3..0d04fb290 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -53,8 +53,8 @@ pub use credentials::{ }; pub use qemu::{ - BootMode, DiskFormat, DisplayMode, MachineType, NetworkMode, QemuConfig, ResourceLimits, - RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, + qemu_version, BootMode, DiskFormat, DisplayMode, MachineType, NetworkMode, QemuConfig, + ResourceLimits, RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, }; pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig}; diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 634a991e3..97e96c574 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -505,6 +505,63 @@ fn allocate_vsock_cid(vhost_fd: File) -> Result<(OwnedFd, u32)> { Err(eyre!("Could not find available VSOCK CID (tried 3-10000)")) } +/// Resolve the QEMU executable used to launch the VM. +fn qemu_binary() -> Result { + std::env::var("QEMU_BIN") + .ok() + .map(Ok) + .unwrap_or_else(|| -> Result<_> { + // RHEL only supports non-emulated, and qemu is an implementation detail + // of higher level virt. + let libexec_qemu = Utf8Path::new("/usr/libexec/qemu-kvm"); + if libexec_qemu.try_exists()? { + Ok(libexec_qemu.to_string()) + } else { + let arch = std::env::consts::ARCH; + Ok(format!("qemu-system-{arch}")) + } + }) + .context("Checking for qemu") +} + +/// Parse the major, minor, and patch version from QEMU's `--version` output. +fn parse_qemu_version(output: &str) -> Option<(u32, u32, u32)> { + let version = output + .lines() + .next()? + .strip_prefix("QEMU emulator version ")? + .split_whitespace() + .next()?; + let numeric = version.split_once('-').map_or(version, |(base, _)| base); + let mut parts = numeric.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// Query the version of the same QEMU executable used to launch VMs. +pub fn qemu_version() -> Result> { + let binary = qemu_binary()?; + let output = Command::new(&binary) + .arg("--version") + .output() + .with_context(|| format!("Checking version of {binary}"))?; + if !output.status.success() { + warn!("Could not get {binary} version"); + return Ok(None); + } + let version_output = String::from_utf8_lossy(&output.stdout); + let version = parse_qemu_version(&version_output); + if version.is_none() { + warn!("Could not parse {binary} version"); + } + Ok(version) +} + /// Spawn QEMU VM process with given configuration and optional extra credential. /// Uses KVM acceleration, memory-backend-memfd for VirtIO-FS compatibility. fn spawn( @@ -520,23 +577,7 @@ fn spawn( config.memory_mb ); - let qemu = std::env::var("QEMU_BIN") - .ok() - .map(Ok) - .unwrap_or_else(|| -> Result<_> { - // RHEL only supports non-emulated, and qemu is an implementation detail - // of higher level virt. - let libexec_qemu = Utf8Path::new("/usr/libexec/qemu-kvm"); - if libexec_qemu.try_exists()? { - Ok(libexec_qemu.to_string()) - } else { - let arch = std::env::consts::ARCH; - Ok(format!("qemu-system-{arch}")) - } - }) - .context("Checking for qemu")?; - - let mut cmd = Command::new(qemu); + let mut cmd = Command::new(qemu_binary()?); // SAFETY: This API is safe to call in a forked child. #[allow(unsafe_code)] unsafe { @@ -992,6 +1033,23 @@ impl RunningQemu { mod tests { use super::*; + #[test] + fn test_parse_qemu_version() { + for (output, expected) in [ + ( + "QEMU emulator version 10.2.2 (qemu-10.2.2-1.fc44)", + Some((10, 2, 2)), + ), + ("QEMU emulator version 11.0.0", Some((11, 0, 0))), + ("QEMU emulator version 11.1.0", Some((11, 1, 0))), + ("QEMU emulator version 12.0.0", Some((12, 0, 0))), + ("QEMU emulator version unknown", None), + ("not a QEMU version", None), + ] { + assert_eq!(parse_qemu_version(output), expected); + } + } + #[test] fn test_virtio_serial_device_creation() { let mut config = QemuConfig::new_direct_boot( diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 490c90014..74d308454 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -134,6 +134,9 @@ const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json"; /// Kernel initramfs magic marker for bootconfig trailer. const BOOTCONFIG_MAGIC: &[u8; 12] = b"#BOOTCONFIG\n"; +/// Linux's LINUX_PE_MAGIC at offset 0x38 in an EFI bootable kernel image. +const LINUX_PE_MAGIC: [u8; 4] = 0x8182_23cd_u32.to_le_bytes(); + // --------------------------------------------------------------------------- // Journal / output mode types // --------------------------------------------------------------------------- @@ -1239,6 +1242,27 @@ fn require_binary(binary: &str) -> Result<()> { Ok(()) } +/// Check if the file is a EFI kernel with zstd compression. +fn is_zstd_efi_zboot(path: &str) -> Result { + use std::io::Read; + + let mut file = std::fs::File::open(path)?; + let mut header = [0u8; 60]; + if file.metadata()?.len() < header.len() as u64 { + return Ok(false); + } + file.read_exact(&mut header)?; + Ok(&header[0..2] == b"MZ" + && &header[4..8] == b"zimg" + && &header[24..29] == b"zstd\0" + && header[56..60] == LINUX_PE_MAGIC) +} + +fn qemu_supports_zstd_zboot() -> Result { + // QEMU 11.0 added zstd decompression to its EFI zboot loader. + Ok(qemu::qemu_version()?.is_some_and(|version| version >= (11, 0, 0))) +} + fn has_bootconfig_trailer(path: &str) -> Result { use std::io::{Read, Seek, SeekFrom}; @@ -1444,6 +1468,23 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { .map_err(|e| eyre!("Failed to copy initramfs: {e}"))?; } + // ARM64 kernels have no built-in decompressors, which means Qemu has to decompress + // the kernel itself. Qemu added zstd support in version 11.0, so if this ie not + // available, manually uncompress the kernel with the unzboot tool. + if std::env::consts::ARCH == "aarch64" + && is_zstd_efi_zboot(kernel_mount)? + && !qemu_supports_zstd_zboot()? + { + require_binary("unzboot")?; + let unwrapped = "/run/qemu/kernel-unwrapped"; + Command::new("unzboot") + .args([kernel_mount, unwrapped]) + .stdout(Stdio::null()) + .run_capture_stderr() + .map_err(|e| eyre!("Unwrapping ARM64 zstd EFI zboot kernel: {e}"))?; + fs::rename(unwrapped, kernel_mount)?; + } + remove_bootconfig(initramfs_mount)?; // Append bcvk units to initramfs @@ -2191,6 +2232,51 @@ Options= mod tests { use super::*; + #[test] + fn test_is_zstd_efi_zboot() -> Result<()> { + use std::io::Write; + + let mut header = [0u8; 60]; + header[0..2].copy_from_slice(b"MZ"); + header[4..8].copy_from_slice(b"zimg"); + header[24..29].copy_from_slice(b"zstd\0"); + header[56..60].copy_from_slice(&[0xcd, 0x23, 0x82, 0x81]); + + for (contents, expected) in [ + (header[..8].to_vec(), false), + (header.to_vec(), true), + ( + { + let mut data = header; + data[4..8].copy_from_slice(b"nope"); + data.to_vec() + }, + false, + ), + ( + { + let mut data = header; + data[24..29].copy_from_slice(b"gzip\0"); + data.to_vec() + }, + false, + ), + ( + { + let mut data = header; + data[56..60].copy_from_slice(b"nope"); + data.to_vec() + }, + false, + ), + ] { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(&contents)?; + assert_eq!(is_zstd_efi_zboot(file.path().to_str().unwrap())?, expected); + } + Ok(()) + } + #[test] fn test_has_bootconfig_trailer() -> Result<()> { use std::io::Write; diff --git a/docs/src/installation.md b/docs/src/installation.md index a023c152e..f793f21e4 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,7 +22,7 @@ For running bcvk: - virtiofsd - Podman - openssh-clients (for libvirt SSH operations) -- kernel-tools (for removing bootconfig before appending to the initramfs) +- kernel-tools (for initramfs bootconfig and ARM64 zstd EFI zboot kernels) Optional: - libvirt (for persistent VM features) From f5a2abb6d6f84ed906877622955d2e4a2c83f8f0 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 14:05:01 +0200 Subject: [PATCH 3/3] ephemeral: Pass SSH key by systemd credentials instead of SMBIOS Aarch64 doesn't support SMBIOS for non-UEFI boots (such as the direct boot we use). So, instead use systemd credentials injected via qemu fw_cfg, as documented in https://systemd.io/CREDENTIALS/. This works on all arches. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/bcvk-qemu/src/credentials.rs | 14 -------------- crates/bcvk-qemu/src/qemu.rs | 23 ++++++++++++++++++++++- crates/kit/src/credentials.rs | 4 ++-- crates/kit/src/run_ephemeral.rs | 16 +++++++++------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/crates/bcvk-qemu/src/credentials.rs b/crates/bcvk-qemu/src/credentials.rs index 385f7bb7a..a448bb6bc 100644 --- a/crates/bcvk-qemu/src/credentials.rs +++ b/crates/bcvk-qemu/src/credentials.rs @@ -183,20 +183,6 @@ pub fn smbios_cred_for_root_ssh(pubkey: &str) -> Result { Ok(r) } -/// Generate kernel command-line argument for root SSH access. -/// -/// Creates a systemd credential for kernel command-line delivery. Less secure -/// than SMBIOS method as credentials are visible in /proc/cmdline and boot logs. -/// -/// Returns a string for use in kernel boot parameters. -#[allow(dead_code)] -pub fn karg_for_root_ssh(pubkey: &str) -> Result { - let k = key_to_root_tmpfiles_d(pubkey); - let encoded = data_encoding::BASE64.encode(k.as_bytes()); - let r = format!("systemd.set_credential_binary=tmpfiles.extra:{encoded}"); - Ok(r) -} - /// Convert SSH public key to systemd tmpfiles.d configuration. /// /// Generates configuration to create `/root/.ssh` directory (0750) and diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 97e96c574..1c7140ba8 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -27,6 +27,8 @@ use crate::VirtiofsConfig; /// The device path for vsock allocation. pub const VHOST_VSOCK: &str = "/dev/vhost-vsock"; +const SYSTEMD_CREDENTIAL_FW_CFG_PREFIX: &str = "opt/io.systemd.credentials/"; + /// VirtIO-FS mount point configuration. #[derive(Debug, Clone)] pub struct VirtiofsMount { @@ -464,6 +466,19 @@ impl QemuConfig { self.fw_cfg_entries.push((name, file_path)); self } + + /// Pass a systemd system credential through QEMU's fw_cfg interface. + /// Only the file path, not its contents, appears in QEMU's arguments. + pub fn add_systemd_credential_file( + &mut self, + credential_name: &str, + file_path: Utf8PathBuf, + ) -> &mut Self { + self.add_fw_cfg( + format!("{SYSTEMD_CREDENTIAL_FW_CFG_PREFIX}{credential_name}"), + file_path, + ) + } } /// Allocate a unique VSOCK CID. @@ -1103,10 +1118,16 @@ mod tests { "opt/com.coreos/config".to_string(), "/test/ignition.json".into(), ); + config.add_systemd_credential_file("tmpfiles.extra", "/test/ssh-credential".into()); // Test that the fw_cfg entry is created correctly - assert_eq!(config.fw_cfg_entries.len(), 1); + assert_eq!(config.fw_cfg_entries.len(), 2); assert_eq!(config.fw_cfg_entries[0].0, "opt/com.coreos/config"); assert_eq!(config.fw_cfg_entries[0].1.as_str(), "/test/ignition.json"); + assert_eq!( + config.fw_cfg_entries[1].0, + "opt/io.systemd.credentials/tmpfiles.extra" + ); + assert_eq!(config.fw_cfg_entries[1].1.as_str(), "/test/ssh-credential"); } } diff --git a/crates/kit/src/credentials.rs b/crates/kit/src/credentials.rs index 41328ad69..ab5434823 100644 --- a/crates/kit/src/credentials.rs +++ b/crates/kit/src/credentials.rs @@ -2,11 +2,11 @@ //! //! This module re-exports credential types and functions from the bcvk-qemu crate. //! Provides functions for injecting configuration into VMs via systemd credentials -//! using SMBIOS firmware variables (preferred) or kernel command-line arguments. +//! using SMBIOS firmware variables or QEMU fw_cfg. //! Supports SSH keys, mount units, environment configuration, and AF_VSOCK setup. // Re-export credential functions from bcvk-qemu that are used within kit pub use bcvk_qemu::{ generate_virtiofs_mount_unit, guest_path_to_unit_name, key_to_root_tmpfiles_d, - smbios_cred_for_root_ssh, smbios_creds_for_storage_opts, storage_opts_tmpfiles_d_lines, + smbios_creds_for_storage_opts, storage_opts_tmpfiles_d_lines, }; diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 74d308454..1c1eedad1 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -125,6 +125,8 @@ use crate::{ /// fw_cfg name for Ignition configuration (per FCOS documentation) const IGNITION_FW_CFG_NAME: &str = "opt/com.coreos/config"; +const SSH_CREDENTIAL_PATH: &str = "/run/qemu/ssh-credential"; + /// virtio-blk serial name for Ignition configuration (per FCOS documentation) const IGNITION_SERIAL_NAME: &str = "ignition"; @@ -1744,10 +1746,14 @@ StandardOutput=file:/dev/virtio-ports/executestatus // Handle SSH key generation and credential injection if opts.common.ssh_keygen { let key_pair = crate::ssh::generate_default_keypair()?; - // Create credential and add to kernel args let pubkey = std::fs::read_to_string(key_pair.public_key_path.as_path())?; - let credential = crate::credentials::smbios_cred_for_root_ssh(&pubkey)?; - qemu_config.add_smbios_credential(credential); + let credential_path = Utf8Path::new(SSH_CREDENTIAL_PATH); + fs::write( + credential_path, + crate::credentials::key_to_root_tmpfiles_d(&pubkey), + ) + .context("Writing SSH credential for QEMU fw_cfg")?; + qemu_config.add_systemd_credential_file("tmpfiles.extra", credential_path.to_owned()); } // Build kernel command line for direct boot. @@ -2094,10 +2100,6 @@ Options= if opts.common.ssh_keygen { qemu_config.enable_ssh_access(None); // Use default port 2222 debug!("Enabled SSH port forwarding: host port 2222 -> guest port 22"); - - // We need to extract the public key from the SSH credential to inject it via SMBIOS - // For now, the credential is already being passed via kernel cmdline - // TODO: Add proper SMBIOS credential injection if needed } // Set main virtiofs configuration for root filesystem (will be spawned by QEMU)