Skip to main content

bootc_lib/install/
osconfig.rs

1use std::borrow::Cow;
2use std::io::Write;
3
4use anyhow::{Context, Result};
5use camino::{Utf8Path, Utf8PathBuf};
6use cap_std::fs::Dir;
7use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
8use fn_error_context::context;
9use ostree_ext::ostree;
10
11const ETC_TMPFILES: &str = "etc/tmpfiles.d";
12const ROOT_SSH_TMPFILE: &str = "bootc-root-ssh.conf";
13
14/// Resolve /root in `root` and write the tmpfiles.d drop-in under `dest`.
15/// With ostree `dest` is the deployment root itself; a composefs deployment
16/// keeps its /etc apart from the image.
17#[context("Injecting root authorized_keys")]
18pub(crate) fn inject_root_ssh_authorized_keys(
19    root: &Dir,
20    dest: &Dir,
21    sepolicy: Option<&ostree::SePolicy>,
22    contents: &str,
23) -> Result<()> {
24    // While not documented right now, this one looks like it does not newline wrap
25    let b64_encoded = ostree_ext::glib::base64_encode(contents.as_bytes());
26
27    // Eagerly resolve the path of /root in order to avoid tmpfiles.d clashes/problems.
28    // If it's local state (i.e. /root -> /var/roothome) then we resolve that symlink now.
29    let roothome_meta = root.symlink_metadata_optional("root")?;
30    let root_path = if roothome_meta.as_ref().filter(|m| m.is_symlink()).is_some() {
31        let path = root.read_link("root")?;
32        Utf8PathBuf::try_from(path)
33            .context("Reading /root symlink")
34            .map(Cow::Owned)?
35    } else {
36        Cow::Borrowed(Utf8Path::new("root"))
37    };
38
39    // See the example in https://systemd.io/CREDENTIALS/
40    let tmpfiles_content =
41        format!("f~ /{root_path}/.ssh/authorized_keys 600 root root - {b64_encoded}\n");
42
43    crate::lsm::ensure_dir_labeled(dest, ETC_TMPFILES, None, 0o755.into(), sepolicy)?;
44    let target = Utf8Path::new(ETC_TMPFILES).join(ROOT_SSH_TMPFILE);
45    crate::lsm::atomic_replace_labeled(dest, &target, 0o644.into(), sepolicy, |w| {
46        w.write_all(tmpfiles_content.as_bytes()).map_err(Into::into)
47    })?;
48
49    println!("Injected: {target}");
50    Ok(())
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_inject_root_ssh_label() -> Result<()> {
59        // Only meaningful where SELinux is enabled
60        if !crate::lsm::selinux_enabled() {
61            return Ok(());
62        }
63        let host = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
64        let Some(policy) = crate::lsm::new_sepolicy_at(&host)? else {
65            return Ok(());
66        };
67        let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
68
69        root.create_dir("etc")?;
70        root.create_dir("root")?;
71        inject_root_ssh_authorized_keys(
72            root,
73            root,
74            Some(&policy),
75            "ssh-ed25519 ABCDE example@demo\n",
76        )?;
77
78        let expected = crate::lsm::require_label(
79            &policy,
80            Utf8Path::new("/etc/tmpfiles.d/bootc-root-ssh.conf"),
81            libc::S_IFREG | 0o644,
82        )?;
83        let f = root.open(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?;
84        let mut buf = [0u8; 1024];
85        let n = rustix::fs::fgetxattr(&f, "security.selinux", &mut buf)?;
86        assert_eq!(
87            std::str::from_utf8(&buf[..n])?.trim_end_matches('\0'),
88            expected.as_str()
89        );
90        Ok(())
91    }
92
93    #[test]
94    fn test_inject_root_ssh_symlinked() -> Result<()> {
95        let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
96
97        // The code expects this to exist, reasonably so
98        root.create_dir("etc")?;
99        // Test with a symlink
100        root.symlink("var/roothome", "root")?;
101        inject_root_ssh_authorized_keys(root, root, None, "ssh-ed25519 ABCDE example@demo\n")
102            .unwrap();
103
104        let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?;
105        assert_eq!(
106            content,
107            "f~ /var/roothome/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n"
108        );
109
110        Ok(())
111    }
112
113    #[test]
114    fn test_inject_root_ssh_dir() -> Result<()> {
115        let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
116
117        root.create_dir("etc")?;
118        root.create_dir("root")?;
119        inject_root_ssh_authorized_keys(root, root, None, "ssh-ed25519 ABCDE example@demo\n")
120            .unwrap();
121
122        let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?;
123        assert_eq!(
124            content,
125            "f~ /root/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n"
126        );
127        Ok(())
128    }
129
130    #[test]
131    fn test_inject_root_ssh_separate_dest() -> Result<()> {
132        let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
133        let dest = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
134
135        root.symlink("var/roothome", "root")?;
136        dest.create_dir("etc")?;
137        inject_root_ssh_authorized_keys(root, dest, None, "ssh-ed25519 ABCDE example@demo\n")
138            .unwrap();
139
140        let content = dest.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?;
141        assert_eq!(
142            content,
143            "f~ /var/roothome/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==\n"
144        );
145        assert!(!root.exists("etc"));
146        Ok(())
147    }
148}