tool: stop most overwriting in the ESP

Since most files (stubs, kernels and initrds) on the ESP are properly
input-addressed or content-addressed now, there is no point in
overwriting them any more. Hence we detect what generations are already
properly installed, and don't reinstall them any more.

This approach leads to two distinct improvements:
* Rollbacks are more reliable, because initrd secrets and stubs do not
  change any more for existing generations (with the necessary exception
  of stubs in case of signature key rotation). In particular, the risk
  of a newer stub breaking (for example, because of bad interactions
  with certain firmware) old and previously working generations is
  avoided.
* Kernels and initrds that are not going to be (re)installed anyway are
  not read and hashed any more. This significantly reduces the I/O and
  CPU time required for the installation process, particularly when
  there is a large number of generations.

The following drawbacks are noted:
* The first time installation is performed after these changes, most of
  the ESP is re-written at a different path; as a result, the disk usage
  increases to roughly the double until the GC is performed.
* If multiple generations share a bare initrd, but have different
  secrets scripts, the final initrds will now be separated, leading to
  increased disk usage. However, this situation should be rare, and the
  previous behavior was arguably incorrect anyway.
* If the files on the ESP are corrupted, running the installation again
  will not overwrite them with the correct versions. Since the files are
  written atomically, this situation should not happen except in case of
  file system corruption, and it is questionable whether overwriting
  really fixes the problem in this case.
This commit is contained in:
Alois Wohlschlager 2023-08-13 18:29:40 +02:00
parent ca070a9eec
commit 4fd37670e2
No known key found for this signature in database
GPG Key ID: E0F59EA5E5216914
2 changed files with 72 additions and 25 deletions

View File

@ -184,6 +184,11 @@ impl Installer {
/// Hence, this function cannot overwrite files of other generations with different contents. /// Hence, this function cannot overwrite files of other generations with different contents.
/// All installed files are added as garbage collector roots. /// All installed files are added as garbage collector roots.
fn install_generation(&mut self, generation: &Generation) -> Result<()> { fn install_generation(&mut self, generation: &Generation) -> Result<()> {
// If the generation is already properly installed, don't overwrite it.
if self.register_installed_generation(generation).is_ok() {
return Ok(());
}
let tempdir = TempDir::new().context("Failed to create temporary directory.")?; let tempdir = TempDir::new().context("Failed to create temporary directory.")?;
let bootspec = &generation.spec.bootspec.bootspec; let bootspec = &generation.spec.bootspec.bootspec;
@ -244,29 +249,10 @@ impl Installer {
&self.esp_paths.esp, &self.esp_paths.esp,
) )
.context("Failed to assemble lanzaboote image.")?; .context("Failed to assemble lanzaboote image.")?;
let stub_inputs = [ let stub_target = self
// Generation numbers can be reused if the latest generation was deleted. .esp_paths
// To detect this, the stub path depends on the actual toplevel used. .linux
("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()), .join(stub_name(generation, &self.key_pair.public_key)?);
// If the key is rotated, the signed stubs must be re-generated.
// So we make their path depend on the public key used for signature.
("public_key", &fs::read(&self.key_pair.public_key)?),
];
let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest(
serde_json::to_string(&stub_inputs).unwrap(),
));
let stub_name = if let Some(specialisation_name) = generation.is_specialised() {
PathBuf::from(format!(
"nixos-generation-{}-specialisation-{}-{}.efi",
generation, specialisation_name, stub_input_hash
))
} else {
PathBuf::from(format!(
"nixos-generation-{}-{}.efi",
generation, stub_input_hash
))
};
let stub_target = self.esp_paths.linux.join(stub_name);
self.gc_roots.extend([&stub_target]); self.gc_roots.extend([&stub_target]);
install_signed(&self.key_pair, &lanzaboote_image, &stub_target) install_signed(&self.key_pair, &lanzaboote_image, &stub_target)
.context("Failed to install the Lanzaboote stub.")?; .context("Failed to install the Lanzaboote stub.")?;
@ -274,6 +260,33 @@ impl Installer {
Ok(()) Ok(())
} }
/// Register the files of an already installed generation as garbage collection roots.
///
/// An error should not be considered fatal; the generation should be (re-)installed instead.
fn register_installed_generation(&mut self, generation: &Generation) -> Result<()> {
let stub_target = self
.esp_paths
.linux
.join(stub_name(generation, &self.key_pair.public_key)?);
let stub = fs::read(&stub_target)?;
let kernel_path = resolve_efi_path(
&self.esp_paths.esp,
pe::read_section_data(&stub, ".kernelp").context("Missing kernel path.")?,
)?;
let initrd_path = resolve_efi_path(
&self.esp_paths.esp,
pe::read_section_data(&stub, ".initrdp").context("Missing initrd path.")?,
)?;
if !kernel_path.exists() && !initrd_path.exists() {
anyhow::bail!("Missing kernel or initrd.");
}
self.gc_roots
.extend([&stub_target, &kernel_path, &initrd_path]);
Ok(())
}
/// Install a content-addressed file to the `EFI/nixos` directory on the ESP. /// Install a content-addressed file to the `EFI/nixos` directory on the ESP.
/// ///
/// It is automatically added to the garbage collector roots. /// It is automatically added to the garbage collector roots.
@ -340,6 +353,40 @@ impl Installer {
} }
} }
/// Translate an EFI path to an absolute path on the mounted ESP.
fn resolve_efi_path(esp: &Path, efi_path: &[u8]) -> Result<PathBuf> {
Ok(esp.join(std::str::from_utf8(&efi_path[1..])?.replace('\\', "/")))
}
/// Compute the file name to be used for the stub of a certain generation, signed with the given key.
///
/// The generated name is input-addressed by the toplevel corresponding to the generation and the public part of the signing key.
fn stub_name(generation: &Generation, public_key: &Path) -> Result<PathBuf> {
let bootspec = &generation.spec.bootspec.bootspec;
let stub_inputs = [
// Generation numbers can be reused if the latest generation was deleted.
// To detect this, the stub path depends on the actual toplevel used.
("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()),
// If the key is rotated, the signed stubs must be re-generated.
// So we make their path depend on the public key used for signature.
("public_key", &fs::read(public_key)?),
];
let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest(
serde_json::to_string(&stub_inputs).unwrap(),
));
if let Some(specialisation_name) = generation.is_specialised() {
Ok(PathBuf::from(format!(
"nixos-generation-{}-specialisation-{}-{}.efi",
generation, specialisation_name, stub_input_hash
)))
} else {
Ok(PathBuf::from(format!(
"nixos-generation-{}-{}.efi",
generation, stub_input_hash
)))
}
}
/// Install a PE file. The PE gets signed in the process. /// Install a PE file. The PE gets signed in the process.
/// ///
/// If the file already exists at the destination, it is overwritten. /// If the file already exists at the destination, it is overwritten.

View File

@ -36,7 +36,7 @@ fn do_not_install_duplicates() -> Result<()> {
} }
#[test] #[test]
fn overwrite_unsigned_images() -> Result<()> { fn do_not_overwrite_images() -> Result<()> {
let esp = tempdir()?; let esp = tempdir()?;
let tmpdir = tempdir()?; let tmpdir = tempdir()?;
let profiles = tempdir()?; let profiles = tempdir()?;
@ -59,7 +59,7 @@ fn overwrite_unsigned_images() -> Result<()> {
let output2 = common::lanzaboote_install(0, esp.path(), generation_links)?; let output2 = common::lanzaboote_install(0, esp.path(), generation_links)?;
assert!(output2.status.success()); assert!(output2.status.success());
assert!(verify_signature(&image1)?); assert!(!verify_signature(&image1)?);
assert!(verify_signature(&image2)?); assert!(verify_signature(&image2)?);
Ok(()) Ok(())