From a9982106a1ebd38ad607827d4c225e0e5437da74 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Sun, 4 Oct 2026 18:58:28 -0400 Subject: [PATCH 1/2] composefs: Inject root SSH keys at install `bootc install --root-ssh-authorized-keys` writes a tmpfiles.d drop-in that provisions root's authorized_keys on first boot, but only the ostree install path called the helper. With the composefs backend the option was accepted and silently dropped: the install succeeded and root key login failed on the booted system. A composefs deployment keeps its /etc in state/deploy//etc, apart from the image that holds the /root symlink the helper resolves. Let the helper take the directory that receives etc/tmpfiles.d separately from the root it reads, and call it once the deployment's /etc exists. The /etc merge carries the drop-in across upgrades like any other locally added file. Assisted-by: AI Signed-off-by: Andrew Dunn --- crates/lib/src/bootc_composefs/boot.rs | 16 ++++++++++++- crates/lib/src/install.rs | 2 +- crates/lib/src/install/osconfig.rs | 33 ++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da8..f8e12b7d22 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -112,7 +112,8 @@ use crate::{ use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState}; use crate::{ composefs_consts::{ - BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED, + BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_RELATIVE, UKI_NAME_PREFIX, + USER_CFG, USER_CFG_STAGED, }, spec::{Bootloader, Host}, }; @@ -2261,6 +2262,19 @@ pub(crate) async fn setup_composefs_boot( ) .await?; + if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { + let deployment = root_setup + .physical_root + .open_dir(format!("{STATE_DIR_RELATIVE}/{}", deploy_id.to_hex())) + .context("Opening deployment state")?; + crate::install::osconfig::inject_root_ssh_authorized_keys( + mounted_root.dir(), + &deployment, + state.load_policy()?.as_ref(), + contents, + )?; + } + Ok(()) } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 8f9f80fc47..4dc6ad0427 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1324,7 +1324,7 @@ async fn install_container( } if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { - osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?; + osconfig::inject_root_ssh_authorized_keys(&root, &root, sepolicy, contents)?; } let aleph = InstallAleph::new( diff --git a/crates/lib/src/install/osconfig.rs b/crates/lib/src/install/osconfig.rs index 6c06f9e26a..65e9370d90 100644 --- a/crates/lib/src/install/osconfig.rs +++ b/crates/lib/src/install/osconfig.rs @@ -11,9 +11,13 @@ use ostree_ext::ostree; const ETC_TMPFILES: &str = "etc/tmpfiles.d"; const ROOT_SSH_TMPFILE: &str = "bootc-root-ssh.conf"; +/// Resolve /root in `root` and write the tmpfiles.d drop-in under `dest`. +/// With ostree `dest` is the deployment root itself; a composefs deployment +/// keeps its /etc apart from the image. #[context("Injecting root authorized_keys")] pub(crate) fn inject_root_ssh_authorized_keys( root: &Dir, + dest: &Dir, sepolicy: Option<&ostree::SePolicy>, contents: &str, ) -> Result<()> { @@ -36,8 +40,8 @@ pub(crate) fn inject_root_ssh_authorized_keys( let tmpfiles_content = format!("f~ /{root_path}/.ssh/authorized_keys 600 root root - {b64_encoded}\n"); - crate::lsm::ensure_dir_labeled(root, ETC_TMPFILES, None, 0o755.into(), sepolicy)?; - let tmpfiles_dir = root.open_dir(ETC_TMPFILES)?; + crate::lsm::ensure_dir_labeled(dest, ETC_TMPFILES, None, 0o755.into(), sepolicy)?; + let tmpfiles_dir = dest.open_dir(ETC_TMPFILES)?; crate::lsm::atomic_replace_labeled( &tmpfiles_dir, ROOT_SSH_TMPFILE, @@ -62,7 +66,8 @@ mod tests { root.create_dir("etc")?; // Test with a symlink root.symlink("var/roothome", "root")?; - inject_root_ssh_authorized_keys(root, None, "ssh-ed25519 ABCDE example@demo\n").unwrap(); + inject_root_ssh_authorized_keys(root, root, None, "ssh-ed25519 ABCDE example@demo\n") + .unwrap(); let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; assert_eq!( @@ -79,7 +84,8 @@ mod tests { root.create_dir("etc")?; root.create_dir("root")?; - inject_root_ssh_authorized_keys(root, None, "ssh-ed25519 ABCDE example@demo\n").unwrap(); + inject_root_ssh_authorized_keys(root, root, None, "ssh-ed25519 ABCDE example@demo\n") + .unwrap(); let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; assert_eq!( @@ -88,4 +94,23 @@ mod tests { ); Ok(()) } + + #[test] + fn test_inject_root_ssh_separate_dest() -> Result<()> { + let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let dest = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + root.symlink("var/roothome", "root")?; + dest.create_dir("etc")?; + inject_root_ssh_authorized_keys(root, dest, None, "ssh-ed25519 ABCDE example@demo\n") + .unwrap(); + + let content = dest.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; + assert_eq!( + content, + "f~ /var/roothome/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n" + ); + assert!(!root.exists("etc")); + Ok(()) + } } From 18b02537af302731a4acd30582b38f89e13a654c Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Mon, 5 Oct 2026 12:05:57 -0400 Subject: [PATCH 2/2] tmt: Check root SSH key injection on install Only the ostree-only install tests in tests-integration covered --root-ssh-authorized-keys, so the composefs backend could drop it unnoticed. test-install-composefs-native already installs to disk with both backends; pass a key on every install there and check the deployment's bootc-root-ssh.conf content and mode. The drop-in is read with decode utf-8 because nushell 0.99.1, which Fedora 44 and EPEL 9 ship, drops the trailing newline when an open --raw stream is collected into a string. Without the previous commit the composefs installs fail this check. Assisted-by: AI Signed-off-by: Andrew Dunn --- tmt/tests/booted/test-install-composefs-native.nu | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tmt/tests/booted/test-install-composefs-native.nu b/tmt/tests/booted/test-install-composefs-native.nu index 59751137bc..1a96ef8c56 100644 --- a/tmt/tests/booted/test-install-composefs-native.nu +++ b/tmt/tests/booted/test-install-composefs-native.nu @@ -26,6 +26,8 @@ const NATIVE = "localhost/bootc-composefs-native" const BOTH = "localhost/bootc-composefs-both" const DISK = "/var/tmp/composefs-native.img" const MNT = "/var/mnt/composefs-native" +const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@example.com" +const KEYS = "/var/tmp/composefs-native-keys" def build [image: string, extra: string] { let td = mktemp -d @@ -46,7 +48,8 @@ def install [image: string, ...args: string] { --security-opt label=type:unconfined_t -v /dev:/dev -v /var/lib/containers:/var/lib/containers -v /var/tmp:/var/tmp $image - bootc install to-disk --disable-selinux --via-loopback ...$args $DISK) + bootc install to-disk --disable-selinux --via-loopback + --root-ssh-authorized-keys $KEYS ...$args $DISK) # Inspect the root partition of the installed disk let parts = sfdisk --json $DISK | from json | get partitiontable @@ -56,8 +59,12 @@ def install [image: string, ...args: string] { mount -o $"ro,loop,offset=($offset)" $DISK $MNT let composefs = ($"($MNT)/composefs" | path exists) and ((ls $"($MNT)/state/deploy" | length) == 1) let ostree = ($"($MNT)/ostree/deploy" | path exists) + let dropin = glob $"($MNT)/state/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf" + | append (glob $"($MNT)/ostree/deploy/*/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf") + | each {|p| {content: (open --raw $p | decode utf-8), mode: (stat -c %a $p | str trim)}} umount $MNT rm -f $DISK + assert equal $dropin [{content: $"f~ /var/roothome/.ssh/authorized_keys 600 root root - ($KEY | encode base64)\n", mode: "644"}] "root ssh drop-in" match [$composefs $ostree] { [true false] => "composefs", [false true] => "ostree", @@ -69,6 +76,7 @@ def main [] { tap begin "composefs-native images default to the composefs backend" bootc image copy-to-storage + $KEY | save -f $KEYS build $NATIVE "RUN rm -f /usr/lib/ostree/prepare-root.conf /etc/ostree/prepare-root.conf" # On the composefs variant, localhost/bootc is itself composefs-native: it # has no prepare-root.conf, and configures its composefs bootloader. @@ -83,5 +91,6 @@ def main [] { } podman rmi $NATIVE $BOTH + rm -f $KEYS tap ok }