From 4f44cb70a2637cc193e121ac4fb6f55f195a4f24 Mon Sep 17 00:00:00 2001 From: nikstur Date: Mon, 2 Jan 2023 01:54:57 +0100 Subject: [PATCH] lanzatool: generate custom os-release --- nix/modules/lanzaboote.nix | 1 - rust/lanzatool/Cargo.lock | 104 +++++++++++++++++++++++++++++ rust/lanzatool/Cargo.toml | 3 + rust/lanzatool/src/generation.rs | 79 +++++++++++++++------- rust/lanzatool/src/install.rs | 10 ++- rust/lanzatool/src/main.rs | 1 + rust/lanzatool/src/os_release.rs | 49 ++++++++++++++ rust/lanzatool/tests/common/mod.rs | 21 ++---- rust/lanzatool/tests/os_release.rs | 54 +++++++++++++++ 9 files changed, 279 insertions(+), 43 deletions(-) create mode 100644 rust/lanzatool/src/os_release.rs create mode 100644 rust/lanzatool/tests/os_release.rs diff --git a/nix/modules/lanzaboote.nix b/nix/modules/lanzaboote.nix index 07ff7ab..2f9105c 100644 --- a/nix/modules/lanzaboote.nix +++ b/nix/modules/lanzaboote.nix @@ -47,7 +47,6 @@ in config = mkIf cfg.enable { boot.bootspec = { enable = true; - extensions."lanzaboote"."osRelease" = config.environment.etc."os-release".source; }; boot.loader.supportsInitrdSecrets = true; boot.loader.external = { diff --git a/rust/lanzatool/Cargo.lock b/rust/lanzatool/Cargo.lock index a7663cb..96cbb95 100644 --- a/rust/lanzatool/Cargo.lock +++ b/rust/lanzatool/Cargo.lock @@ -183,6 +183,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dissimilar" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5f0c7e4bd266b8ab2550e6238d2e74977c23c15536ac7be45e9c95e2e3fbbb" + [[package]] name = "doc-comment" version = "0.3.3" @@ -195,6 +201,16 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "expect-test" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d4661aca38d826eb7c72fe128e4238220616de4c0cc00db7bfc38e2e1364dd3" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fastrand" version = "1.8.0" @@ -204,6 +220,18 @@ dependencies = [ "instant", ] +[[package]] +name = "filetime" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e884668cd0c7480504233e951174ddc3b382f7c2666e3b7310b5c4e7b0c37f9" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys", +] + [[package]] name = "generic-array" version = "0.14.6" @@ -284,12 +312,15 @@ dependencies = [ "blake3", "bootspec", "clap", + "expect-test", + "filetime", "goblin", "nix", "rand", "serde", "serde_json", "tempfile", + "time", "walkdir", ] @@ -591,6 +622,22 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95059e91184749cb66be6dc994f67f182b6d897cb3df74a5bf66b5e709295fd8" +[[package]] +name = "time" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" +dependencies = [ + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + [[package]] name = "typenum" version = "1.15.0" @@ -665,3 +712,60 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" diff --git a/rust/lanzatool/Cargo.toml b/rust/lanzatool/Cargo.toml index 45eb481..8e37e04 100644 --- a/rust/lanzatool/Cargo.toml +++ b/rust/lanzatool/Cargo.toml @@ -17,7 +17,10 @@ blake3 = "1.3.3" # TODO: wait for a upstream release and pin it. bootspec = { git = "https://github.com/DeterminateSystems/bootspec" } walkdir = "2.3.2" +time = "0.3.17" [dev-dependencies] assert_cmd = "2.0.7" +expect-test = "1.4.0" +filetime = "0.2.19" rand = "0.8.5" diff --git a/rust/lanzatool/src/generation.rs b/rust/lanzatool/src/generation.rs index 0acb234..2d15dea 100644 --- a/rust/lanzatool/src/generation.rs +++ b/rust/lanzatool/src/generation.rs @@ -1,24 +1,20 @@ use std::fmt; use std::fs; +use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use bootspec::generation::Generation as BootspecGeneration; use bootspec::BootJson; use bootspec::SpecialisationName; -use serde::de::IntoDeserializer; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecureBootExtension { - #[serde(rename = "osRelease")] - pub os_release: PathBuf, -} +/// (Possibly) extended Bootspec. +/// +/// This struct currently does not have any extensions. We keep it around so that extension becomes +/// easy if/when we have to do it. #[derive(Debug, Clone)] pub struct ExtendedBootJson { pub bootspec: BootJson, - pub extensions: SecureBootExtension, } /// A system configuration. @@ -51,34 +47,19 @@ impl Generation { .try_into() .map_err(|err: &'static str| anyhow!(err))?; - let extensions = Self::extract_extensions(&bootspec)?; - Ok(Self { version: link.version, specialisation_name: None, - spec: ExtendedBootJson { - bootspec, - extensions, - }, + spec: ExtendedBootJson { bootspec }, }) } - fn extract_extensions(bootspec: &BootJson) -> Result { - Ok(Deserialize::deserialize( - bootspec.extensions.get("lanzaboote") - .context("Failed to extract Lanzaboote-specific extension from Bootspec, missing lanzaboote field in `extensions`")? - .clone() - .into_deserializer() - )?) - } - pub fn specialise(&self, name: &SpecialisationName, bootspec: &BootJson) -> Result { Ok(Self { version: self.version, specialisation_name: Some(name.clone()), spec: ExtendedBootJson { bootspec: bootspec.clone(), - extensions: Self::extract_extensions(bootspec)?, }, }) } @@ -86,6 +67,28 @@ impl Generation { pub fn is_specialized(&self) -> Option { self.specialisation_name.clone() } + + /// Describe the generation in a single line. + /// + /// Emulates how NixOS's current systemd-boot-builder.py describes generations so that the user + /// interface remains similar. + /// + /// This is currently implemented by poking around the filesystem to find the necessary data. + /// Ideally, the needed data should be included in the bootspec. + pub fn describe(&self) -> Result { + let toplevel = &self.spec.bootspec.toplevel.0; + + let nixos_version = fs::read_to_string(toplevel.join("nixos-version")) + .unwrap_or_else(|_| String::from("Unknown")); + let kernel_version = + read_kernel_version(toplevel).context("Failed to read kernel version.")?; + let build_time = read_build_time(toplevel).unwrap_or_else(|_| String::from("Unknown")); + + Ok(format!( + "Generation {} NixOS {}, Linux Kernel {}, Built on {}", + self.version, nixos_version, kernel_version, build_time + )) + } } impl fmt::Display for Generation { @@ -94,6 +97,32 @@ impl fmt::Display for Generation { } } +/// Read the kernel version from the name of a directory inside the toplevel directory. +/// +/// The path looks something like this: $toplevel/kernel-modules/lib/modules/6.1.1 +fn read_kernel_version(toplevel: &Path) -> Result { + let path = fs::read_dir(toplevel.join("kernel-modules/lib/modules"))? + .into_iter() + .next() + .transpose()? + .map(|x| x.path()) + .with_context(|| format!("Failed to read directory {:?}.", toplevel))?; + + let file_name = path + .file_name() + .and_then(|x| x.to_str()) + .context("Failed to convert path to filename string.")?; + + Ok(String::from(file_name)) +} + +fn read_build_time(path: &Path) -> Result { + let build_time = time::OffsetDateTime::from_unix_timestamp(fs::metadata(path)?.mtime())? + .date() + .to_string(); + Ok(build_time) +} + /// A link pointing to a generation. /// /// Can be built from a symlink in /nix/var/nix/profiles/ alone because the name of the diff --git a/rust/lanzatool/src/install.rs b/rust/lanzatool/src/install.rs index 07f07ff..1621fc4 100644 --- a/rust/lanzatool/src/install.rs +++ b/rust/lanzatool/src/install.rs @@ -10,6 +10,7 @@ use tempfile::tempdir; use crate::esp::EspPaths; use crate::gc::Roots; use crate::generation::{Generation, GenerationLink}; +use crate::os_release::OsRelease; use crate::pe; use crate::signature::KeyPair; @@ -113,7 +114,6 @@ impl Installer { fn install_generation(&mut self, generation: &Generation) -> Result<()> { let bootspec = &generation.spec.bootspec; - let secureboot_extensions = &generation.spec.extensions; let esp_paths = EspPaths::new(&self.esp, generation)?; self.gc_roots.extend(esp_paths.to_iter()); @@ -129,6 +129,12 @@ impl Installer { // TODO(Raito): prove to niksnur this is actually acceptable. let secure_temp_dir = tempdir()?; + let os_release = OsRelease::from_generation(generation) + .context("Failed to build OsRelease from generation.")?; + let os_release_path = secure_temp_dir.path().join("os-release"); + fs::write(&os_release_path, os_release.to_string().as_bytes()) + .with_context(|| format!("Failed to write os-release file: {:?}", os_release_path))?; + println!("Appending secrets to initrd..."); let initrd_location = secure_temp_dir.path().join("initrd"); @@ -164,7 +170,7 @@ impl Installer { let lanzaboote_image = pe::lanzaboote_image( &secure_temp_dir, &self.lanzaboote_stub, - &secureboot_extensions.os_release, + &os_release_path, &kernel_cmdline, &esp_paths.kernel, &esp_paths.initrd, diff --git a/rust/lanzatool/src/main.rs b/rust/lanzatool/src/main.rs index 06de0c8..e502d9d 100644 --- a/rust/lanzatool/src/main.rs +++ b/rust/lanzatool/src/main.rs @@ -3,6 +3,7 @@ mod esp; mod gc; mod generation; mod install; +mod os_release; mod pe; mod signature; diff --git a/rust/lanzatool/src/os_release.rs b/rust/lanzatool/src/os_release.rs new file mode 100644 index 0000000..c030b7d --- /dev/null +++ b/rust/lanzatool/src/os_release.rs @@ -0,0 +1,49 @@ +use std::collections::BTreeMap; +use std::fmt; + +use anyhow::{Context, Result}; + +use crate::generation::Generation; + +/// An os-release file represented by a BTreeMap. +/// +/// This is implemented using a map, so that it can be easily extended in the future (e.g. by +/// reading the original os-release and patching it). +/// +/// The BTreeMap is used over a HashMap, so that the keys are ordered. This is irrelevant for +/// systemd-boot (which does not care about order when reading the os-release file) but is useful +/// for testing. Ordered keys allow using snapshot tests. +pub struct OsRelease(BTreeMap<&'static str, String>); + +impl OsRelease { + pub fn from_generation(generation: &Generation) -> Result { + let mut map = BTreeMap::new(); + + // Because of a null pointer dereference, `bootctl` segfaults when no ID field is present + // in the .osrel section of the stub. + // Fixed in https://github.com/systemd/systemd/pull/25953 + // + // Because the ID field here does not have the same meaning as in a real os-release file, + // it is fine to use a dummy value. + map.insert("ID", String::from("lanza")); + map.insert("PRETTY_NAME", generation.spec.bootspec.label.clone()); + map.insert( + "VERSION_ID", + generation + .describe() + .context("Failed to describe generation.")?, + ); + + Ok(Self(map)) + } +} + +/// Display OsRelease in the format of an os-release file. +impl fmt::Display for OsRelease { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for (key, value) in &self.0 { + writeln!(f, "{}={}", key, value)? + } + Ok(()) + } +} diff --git a/rust/lanzatool/tests/common/mod.rs b/rust/lanzatool/tests/common/mod.rs index 5df635f..71a7cde 100644 --- a/rust/lanzatool/tests/common/mod.rs +++ b/rust/lanzatool/tests/common/mod.rs @@ -21,6 +21,8 @@ pub fn setup_generation_link( version: u64, ) -> Result { let toplevel = setup_toplevel(tmpdir).context("Failed to setup toplevel")?; + // Explicitly set modification time so that snapshot test of os-release reliably works. + filetime::set_file_mtime(&toplevel, filetime::FileTime::zero())?; let bootspec = json!({ "v1": { @@ -73,7 +75,8 @@ fn setup_toplevel(tmpdir: &Path) -> Result { let initrd_path = toplevel.join("initrd"); let kernel_path = toplevel.join("kernel"); let systemd_path = toplevel.join("systemd"); - let os_release_path = toplevel.join("os-release"); + let nixos_version_path = toplevel.join("nixos-version"); + let kernel_modules_path = toplevel.join("kernel-modules/lib/modules/6.1.1"); // To simplify the test setup, we use the systemd stub for all PE binaries used by lanzatool. // Lanzatool doesn't care whether its actually a kernel or initrd but only whether it can @@ -82,7 +85,8 @@ fn setup_toplevel(tmpdir: &Path) -> Result { fs::copy(&test_systemd_stub, initrd_path)?; fs::copy(&test_systemd_stub, kernel_path)?; symlink(&test_systemd, systemd_path)?; - setup_os_release(&os_release_path).context("Failed to setup os-release")?; + fs::write(nixos_version_path, b"23.05")?; + fs::create_dir_all(kernel_modules_path)?; Ok(toplevel) } @@ -95,19 +99,6 @@ fn random_string(length: usize) -> String { .collect() } -fn setup_os_release(path: &Path) -> Result<()> { - let content = r#" - ID=lanzaos - NAME=LanzaOS - PRETTY_NAME="LanzaOS 23.05 (Goat)" - VERSION="23.05 (Goat)" - "#; - - let mut file = fs::File::create(path)?; - file.write_all(content.as_bytes())?; - Ok(()) -} - /// Call the `lanzaboote install` command. pub fn lanzaboote_install( config_limit: u64, diff --git a/rust/lanzatool/tests/os_release.rs b/rust/lanzatool/tests/os_release.rs new file mode 100644 index 0000000..95c6348 --- /dev/null +++ b/rust/lanzatool/tests/os_release.rs @@ -0,0 +1,54 @@ +use std::fs; + +use anyhow::{Context, Result}; +use expect_test::expect; +use tempfile::tempdir; + +mod common; + +#[test] +fn generate_expected_os_release() -> Result<()> { + let esp_mountpoint = tempdir()?; + let tmpdir = tempdir()?; + let profiles = tempdir()?; + + let generation_link = common::setup_generation_link(tmpdir.path(), profiles.path(), 1) + .expect("Failed to setup generation link"); + + let output0 = common::lanzaboote_install(0, esp_mountpoint.path(), vec![generation_link])?; + assert!(output0.status.success()); + + let stub_data = fs::read( + esp_mountpoint + .path() + .join("EFI/Linux/nixos-generation-1.efi"), + )?; + let os_release_section = pe_section(&stub_data, ".osrel") + .context("Failed to read .osrelease PE section.")? + .to_owned(); + + let expected = expect![[r#" + ID=lanza + PRETTY_NAME=LanzaOS + VERSION_ID=Generation 1 NixOS 23.05, Linux Kernel 6.1.1, Built on 1970-01-01 + "#]]; + + expected.assert_eq(&String::from_utf8(os_release_section)?); + + Ok(()) +} + +fn pe_section<'a>(file_data: &'a [u8], section_name: &str) -> Option<&'a [u8]> { + let pe_binary = goblin::pe::PE::parse(file_data).ok()?; + + pe_binary + .sections + .iter() + .find(|s| s.name().unwrap() == section_name) + .and_then(|s| { + let section_start: usize = s.pointer_to_raw_data.try_into().ok()?; + assert!(s.virtual_size <= s.size_of_raw_data); + let section_end: usize = section_start + usize::try_from(s.virtual_size).ok()?; + Some(&file_data[section_start..section_end]) + }) +}