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/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/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..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. @@ -505,6 +520,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 +592,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 +1048,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( @@ -1045,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 97d41a6bc..1c1eedad1 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -125,12 +125,20 @@ 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"; /// 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"; + +/// 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 // --------------------------------------------------------------------------- @@ -1231,6 +1239,59 @@ fn check_required_container_binaries() -> Result<()> { Ok(()) } +fn require_binary(binary: &str) -> Result<()> { + which::which(binary).map_err(|_| eyre!("Missing required executable: {binary}"))?; + 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}; + + 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 +1470,25 @@ 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 // This includes: // - /etc overlay and /var ephemeral services (run in initramfs) @@ -1666,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. @@ -2016,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) @@ -2154,6 +2234,71 @@ 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; + + 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..f793f21e4 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 initramfs bootconfig and ARM64 zstd EFI zboot kernels) Optional: - libvirt (for persistent VM features)