Merge pull request #50 from nix-community/os-release

Lanzatool: generate custom os-release
This commit is contained in:
nikstur 2023-01-06 22:11:27 +01:00 committed by GitHub
commit b79dea1fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 279 additions and 43 deletions

View File

@ -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 = {

View File

@ -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"

View File

@ -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"

View File

@ -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<SecureBootExtension> {
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<Self> {
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<SpecialisationName> {
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<String> {
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<String> {
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<String> {
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

View File

@ -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,

View File

@ -3,6 +3,7 @@ mod esp;
mod gc;
mod generation;
mod install;
mod os_release;
mod pe;
mod signature;

View File

@ -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<Self> {
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(())
}
}

View File

@ -21,6 +21,8 @@ pub fn setup_generation_link(
version: u64,
) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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,

View File

@ -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])
})
}