From 5a404fb8e0561a4ed84844fe220f900556c9651c Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 2 Feb 2024 08:19:55 -0500 Subject: [PATCH] install: Add support for `--root-ssh-authorized-keys` The current `bootc install` model is VERY opinionated: we install the running container image to disk, and that is (almost!) it. The only non-container out of band state that we support injecting right now is kargs (via `--karg`) - we know we need this for machine local kernel arguments. (We do have a current outstanding PR to add a highly generic mechanism to inject arbitrary files in `/etc`, but I want to think about that more) THis current strict stance is quite painful for a use case like "take a generic container image and bootc install to-filesystem --alongside" in a cloud environment, because the generic container may not have cloud-init. With this change it becomes extremely convenient to: - Boot generic cloud image (e.g. AMI with apt/dnf + cloud-init) - cloud-init fetches SSH keys from hypervisor (instance metadata) - podman run -v /root/.ssh/authorized_keys:/keys:ro bootc install ... --root-ssh-authorized-keys=/keys` And then the instance will carry forward those hypervisor-provided keys but without a dependency on cloud-init. Another use case for this of course is being the backend of things like Anaconda's kickstart verbs which support injecting SSH keys. Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 4 +++- lib/src/install.rs | 26 +++++++++++++++++++++++ lib/src/install/osconfig.rs | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 lib/src/install/osconfig.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99894b250..02f4102e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,11 +132,13 @@ jobs: - name: Integration tests run: | set -xeuo pipefail + echo 'ssh-ed25519 ABC0123 testcase@example.com' > test_authorized_keys sudo podman run --rm -ti --privileged --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable \ quay.io/centos-bootc/fedora-bootc-dev:eln bootc install to-filesystem \ - --karg=foo=bar --disable-selinux --replace=alongside /target + --karg=foo=bar --disable-selinux --replace=alongside --root-ssh-authorized-keys=test_authorized_keys /target ls -al /boot/loader/ sudo grep foo=bar /boot/loader/entries/*.conf + grep authorized_keys /ostree/deploy/default/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf # TODO fix https://github.com/containers/bootc/pull/137 sudo chattr -i / /ostree/deploy/default/deploy/* sudo rm /ostree/deploy/default -rf diff --git a/lib/src/install.rs b/lib/src/install.rs index 2db5a4cc0..4f408d823 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -8,6 +8,7 @@ // and filesystem setup. pub(crate) mod baseline; pub(crate) mod config; +pub(crate) mod osconfig; use std::io::BufWriter; use std::io::Write; @@ -132,6 +133,16 @@ pub(crate) struct InstallConfigOpts { /// Add a kernel argument karg: Option>, + /// The path to an `authorized_keys` that will be injected into the `root` account. + /// + /// The implementation of this uses systemd `tmpfiles.d`, writing to a file named + /// `/etc/tmpfiles.d/bootc-root-ssh.conf`. This will have the effect that by default, + /// the SSH credentials will be set if not present. The intention behind this + /// is to allow mounting the whole `/root` home directory as a `tmpfs`, while still + /// getting the SSH key replaced on boot. + #[clap(long)] + root_ssh_authorized_keys: Option, + /// Perform configuration changes suitable for a "generic" disk image. /// At the moment: /// @@ -261,6 +272,8 @@ pub(crate) struct State { pub(crate) config_opts: InstallConfigOpts, pub(crate) target_imgref: ostree_container::OstreeImageReference, pub(crate) install_config: config::InstallConfiguration, + /// The parsed contents of the authorized_keys (not the file path) + pub(crate) root_ssh_authorized_keys: Option, } impl State { @@ -596,6 +609,10 @@ async fn initialize_ostree_root_from_self( } f.flush()?; + if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { + osconfig::inject_root_ssh_authorized_keys(&root, contents)?; + } + let uname = rustix::system::uname(); let labels = crate::status::labels_of_config(&imgstate.configuration); @@ -944,6 +961,14 @@ async fn prepare_install( let install_config = config::load_config()?; tracing::debug!("Loaded install configuration"); + // Eagerly read the file now to ensure we error out early if e.g. it doesn't exist, + // instead of much later after we're 80% of the way through an install. + let root_ssh_authorized_keys = config_opts + .root_ssh_authorized_keys + .as_ref() + .map(|p| std::fs::read_to_string(p).with_context(|| format!("Reading {p}"))) + .transpose()?; + // Create our global (read-only) state which gets wrapped in an Arc // so we can pass it to worker threads too. Right now this just // combines our command line options along with some bind mounts from the host. @@ -954,6 +979,7 @@ async fn prepare_install( config_opts, target_imgref, install_config, + root_ssh_authorized_keys, }); Ok(state) diff --git a/lib/src/install/osconfig.rs b/lib/src/install/osconfig.rs new file mode 100644 index 000000000..56791bcc0 --- /dev/null +++ b/lib/src/install/osconfig.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use fn_error_context::context; + +const ETC_TMPFILES: &str = "etc/tmpfiles.d"; +const ROOT_SSH_TMPFILE: &str = "bootc-root-ssh.conf"; + +#[context("Injecting root authorized_keys")] +pub(crate) fn inject_root_ssh_authorized_keys(root: &Dir, contents: &str) -> Result<()> { + // While not documented right now, this one looks like it does not newline wrap + let b64_encoded = ostree_ext::glib::base64_encode(contents.as_bytes()); + // See the example in https://systemd.io/CREDENTIALS/ + // HOWEVER: I chose to use `f` and not `f~` in order to allow this to be overwritten + // by something else later - e.g. for later key rotation support. The real more ideal + // model here would be to have e.g .`/usr/lib/ssh/authorized_keys/root` that would be + // *merged at runtime* with the home directory (if present) - but would be clearly immutable + // underneath `/usr`. + let tmpfiles_content = format!("f /root/.ssh/authorized_keys 600 root root - {b64_encoded}"); + + let tmpfiles_dir = Utf8Path::new(ETC_TMPFILES); + root.create_dir_all(tmpfiles_dir)?; + let target = tmpfiles_dir.join(ROOT_SSH_TMPFILE); + root.atomic_write(target, &tmpfiles_content) + .map_err(Into::into) +} + +#[test] +fn test_inject_root_ssh() -> Result<()> { + let root = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + + inject_root_ssh_authorized_keys(root, "ssh-ed25519 ABCDE example@demo\n").unwrap(); + + let content = root.read_to_string(format!("etc/tmpfiles.d/{ROOT_SSH_TMPFILE}"))?; + assert_eq!( + content, + "f /root/.ssh/authorized_keys 600 root root - c3NoLWVkMjU1MTkgQUJDREUgZXhhbXBsZUBkZW1vCg==" + ); + Ok(()) +}