diff --git a/Cargo.lock b/Cargo.lock index bccd9540..e5ae6fe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,6 +637,19 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -1951,6 +1964,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -2083,6 +2102,23 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-reinstall-bootc" +version = "0.1.9" +dependencies = [ + "anyhow", + "bootc-utils", + "clap", + "dialoguer", + "log", + "rustix", + "serde", + "serde_json", + "serde_yaml", + "tracing", + "uzers", +] + [[package]] name = "tar" version = "0.4.43" @@ -2418,6 +2454,16 @@ dependencies = [ "serde", ] +[[package]] +name = "uzers" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df81ff504e7d82ad53e95ed1ad5b72103c11253f39238bcc0235b90768a97dd" +dependencies = [ + "libc", + "log", +] + [[package]] name = "valuable" version = "0.1.0" @@ -2727,6 +2773,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zstd" version = "0.13.2" diff --git a/Cargo.toml b/Cargo.toml index b0d965d6..90e155fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "cli", + "system-reinstall-bootc", "lib", "ostree-ext", "utils", diff --git a/Makefile b/Makefile index 7a940cac..feb3679b 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ all: install: install -D -m 0755 -t $(DESTDIR)$(prefix)/bin target/release/bootc + install -D -m 0755 -t $(DESTDIR)$(prefix)/bin target/release/system-reinstall-bootc install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/bound-images.d install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/kargs.d ln -s /sysroot/ostree/bootc/storage $(DESTDIR)$(prefix)/lib/bootc/storage diff --git a/system-reinstall-bootc/Cargo.toml b/system-reinstall-bootc/Cargo.toml new file mode 100644 index 00000000..c94efc41 --- /dev/null +++ b/system-reinstall-bootc/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "system-reinstall-bootc" +version = "0.1.9" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/containers/bootc" +readme = "README.md" +publish = false +# For now don't bump this above what is currently shipped in RHEL9. +rust-version = "1.75.0" + +# See https://github.com/coreos/cargo-vendor-filterer +[package.metadata.vendor-filter] +# For now we only care about tier 1+2 Linux. (In practice, it's unlikely there is a tier3-only Linux dependency) +platforms = ["*-unknown-linux-gnu"] + +[dependencies] +anyhow = { workspace = true } +bootc-utils = { path = "../utils" } +clap = { workspace = true, features = ["derive"] } +dialoguer = "0.11.0" +log = "0.4.21" +rustix = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = "0.9.22" +tracing = { workspace = true } +uzers = "0.12.1" + +[lints] +workspace = true diff --git a/system-reinstall-bootc/sample_config.yaml b/system-reinstall-bootc/sample_config.yaml new file mode 100644 index 00000000..1d40e88f --- /dev/null +++ b/system-reinstall-bootc/sample_config.yaml @@ -0,0 +1,2 @@ +# The bootc container image to install +bootc_image: quay.io/fedora/fedora-bootc:41 diff --git a/system-reinstall-bootc/src/config/cli.rs b/system-reinstall-bootc/src/config/cli.rs new file mode 100644 index 00000000..7d752488 --- /dev/null +++ b/system-reinstall-bootc/src/config/cli.rs @@ -0,0 +1,7 @@ +use clap::Parser; + +#[derive(Parser)] +pub(crate) struct Cli { + /// The bootc container image to install, e.g. quay.io/fedora/fedora-bootc:41 + pub(crate) bootc_image: String, +} diff --git a/system-reinstall-bootc/src/config/mod.rs b/system-reinstall-bootc/src/config/mod.rs new file mode 100644 index 00000000..10fb144a --- /dev/null +++ b/system-reinstall-bootc/src/config/mod.rs @@ -0,0 +1,52 @@ +use anyhow::{ensure, Context, Result}; +use clap::Parser; +use serde::{Deserialize, Serialize}; + +mod cli; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReinstallConfig { + /// The bootc image to install on the system. + pub(crate) bootc_image: String, + + /// The raw CLI arguments that were used to invoke the program. None if the config was loaded + /// from a file. + #[serde(skip_deserializing)] + cli_flags: Option>, +} + +impl ReinstallConfig { + pub fn parse_from_cli(cli: cli::Cli) -> Self { + Self { + bootc_image: cli.bootc_image, + cli_flags: Some(std::env::args().collect::>()), + } + } + + pub fn load() -> Result { + Ok(match std::env::var("BOOTC_REINSTALL_CONFIG") { + Ok(config_path) => { + ensure_no_cli_args()?; + + serde_yaml::from_slice( + &std::fs::read(&config_path) + .context("reading BOOTC_REINSTALL_CONFIG file {config_path}")?, + ) + .context("parsing BOOTC_REINSTALL_CONFIG file {config_path}")? + } + Err(_) => ReinstallConfig::parse_from_cli(cli::Cli::parse()), + }) + } +} + +fn ensure_no_cli_args() -> Result<()> { + let num_args = std::env::args().len(); + + ensure!( + num_args == 1, + "BOOTC_REINSTALL_CONFIG is set, but there are {num_args} CLI arguments. BOOTC_REINSTALL_CONFIG is meant to be used with no arguments." + ); + + Ok(()) +} diff --git a/system-reinstall-bootc/src/main.rs b/system-reinstall-bootc/src/main.rs new file mode 100644 index 00000000..fd069fcd --- /dev/null +++ b/system-reinstall-bootc/src/main.rs @@ -0,0 +1,47 @@ +//! The main entrypoint for the bootc system reinstallation CLI + +use anyhow::{ensure, Context, Result}; +use bootc_utils::CommandRunExt; +use rustix::process::getuid; + +mod config; +mod podman; +mod prompt; +pub(crate) mod users; + +const ROOT_KEY_MOUNT_POINT: &str = "/bootc_authorized_ssh_keys/root"; + +fn run() -> Result<()> { + bootc_utils::initialize_tracing(); + tracing::trace!("starting {}", env!("CARGO_PKG_NAME")); + + // Rootless podman is not supported by bootc + ensure!(getuid().is_root(), "Must run as the root user"); + + let config = config::ReinstallConfig::load().context("loading config")?; + + let mut reinstall_podman_command = + podman::command(&config.bootc_image, &prompt::get_root_key()?); + + println!(); + + println!("Going to run command {:?}", reinstall_podman_command); + + prompt::temporary_developer_protection_prompt()?; + + reinstall_podman_command + .run_with_cmd_context() + .context("running reinstall command")?; + + Ok(()) +} + +fn main() { + // In order to print the error in a custom format (with :#) our + // main simply invokes a run() where all the work is done. + // This code just captures any errors. + if let Err(e) = run() { + tracing::error!("{:#}", e); + std::process::exit(1); + } +} diff --git a/system-reinstall-bootc/src/podman.rs b/system-reinstall-bootc/src/podman.rs new file mode 100644 index 00000000..993be2be --- /dev/null +++ b/system-reinstall-bootc/src/podman.rs @@ -0,0 +1,60 @@ +use std::process::Command; + +use super::ROOT_KEY_MOUNT_POINT; +use crate::users::UserKeys; + +pub(crate) fn command(image: &str, root_key: &Option) -> Command { + let mut podman_command_and_args = [ + // We use podman to run the bootc container. This might change in the future to remove the + // podman dependency. + "podman", + "run", + // The container needs to be privileged, as it heavily modifies the host + "--privileged", + // The container needs to access the host's PID namespace to mount host directories + "--pid=host", + // Since https://github.com/containers/bootc/pull/919 this mount should not be needed, but + // some reason with e.g. quay.io/fedora/fedora-bootc:41 it is still needed. + "-v", + "/var/lib/containers:/var/lib/containers", + ] + .map(String::from) + .to_vec(); + + let mut bootc_command_and_args = [ + "bootc", + "install", + // We're replacing the current root + "to-existing-root", + // The user already knows they're reinstalling their machine, that's the entire purpose of + // this binary. Since this is no longer an "arcane" bootc command, we can safely avoid this + // timed warning prompt. TODO: Discuss in https://github.com/containers/bootc/discussions/1060 + "--acknowledge-destructive", + ] + .map(String::from) + .to_vec(); + + if let Some(root_key) = root_key.as_ref() { + let root_authorized_keys_path = root_key.authorized_keys_path.clone(); + + podman_command_and_args.push("-v".to_string()); + podman_command_and_args.push(format!( + "{root_authorized_keys_path}:{ROOT_KEY_MOUNT_POINT}" + )); + + bootc_command_and_args.push("--root-ssh-authorized-keys".to_string()); + bootc_command_and_args.push(ROOT_KEY_MOUNT_POINT.to_string()); + } + + let all_args = [ + podman_command_and_args, + vec![image.to_string()], + bootc_command_and_args, + ] + .concat(); + + let mut command = Command::new(&all_args[0]); + command.args(&all_args[1..]); + + command +} diff --git a/system-reinstall-bootc/src/prompt.rs b/system-reinstall-bootc/src/prompt.rs new file mode 100644 index 00000000..acab0b0a --- /dev/null +++ b/system-reinstall-bootc/src/prompt.rs @@ -0,0 +1,81 @@ +use crate::users::{get_all_users_keys, UserKeys}; +use anyhow::{ensure, Context, Result}; + +fn prompt_single_user(user: &crate::users::UserKeys) -> Result> { + let prompt = format!( + "Found only one user ({}) with {} SSH authorized keys. Would you like to install this user in the system?", + user.user, + user.num_keys(), + ); + let answer = ask_yes_no(&prompt, true)?; + Ok(if answer { vec![&user] } else { vec![] }) +} + +fn prompt_user_selection( + all_users: &[crate::users::UserKeys], +) -> Result> { + let keys: Vec = all_users.iter().map(|x| x.user.clone()).collect(); + + // TODO: Handle https://github.com/console-rs/dialoguer/issues/77 + let selected_user_indices: Vec = dialoguer::MultiSelect::new() + .with_prompt("Select the users you want to install in the system (along with their authorized SSH keys)") + .items(&keys) + .interact()?; + + Ok(selected_user_indices + .iter() + // Safe unwrap because we know the index is valid + .map(|x| all_users.get(*x).unwrap()) + .collect()) +} + +/// Temporary safety mechanism to stop devs from running it on their dev machine. TODO: Discuss +/// final prompting UX in https://github.com/containers/bootc/discussions/1060 +pub(crate) fn temporary_developer_protection_prompt() -> Result<()> { + // Print an empty line so that the warning stands out from the rest of the output + println!(); + + let prompt = "THIS WILL REINSTALL YOUR SYSTEM! Are you sure you want to continue?"; + let answer = ask_yes_no(prompt, false)?; + + if !answer { + println!("Exiting without reinstalling the system."); + std::process::exit(0); + } + + Ok(()) +} + +pub(crate) fn ask_yes_no(prompt: &str, default: bool) -> Result { + dialoguer::Confirm::new() + .with_prompt(prompt) + .default(default) + .wait_for_newline(true) + .interact() + .context("prompting") +} + +/// For now we only support the root user. This function returns the root user's SSH +/// authorized_keys. In the future, when bootc supports multiple users, this function will need to +/// be updated to return the SSH authorized_keys for all the users selected by the user. +pub(crate) fn get_root_key() -> Result> { + let users = get_all_users_keys()?; + if users.is_empty() { + return Ok(None); + } + + let selected_users = if users.len() == 1 { + prompt_single_user(&users[0])? + } else { + prompt_user_selection(&users)? + }; + + ensure!( + selected_users.iter().all(|x| x.user == "root"), + "Only importing the root user keys is supported for now" + ); + + let root_key = selected_users.into_iter().find(|x| x.user == "root"); + + Ok(root_key.cloned()) +} diff --git a/system-reinstall-bootc/src/users.rs b/system-reinstall-bootc/src/users.rs new file mode 100644 index 00000000..9fa85c71 --- /dev/null +++ b/system-reinstall-bootc/src/users.rs @@ -0,0 +1,150 @@ +use anyhow::{Context, Result}; +use bootc_utils::CommandRunExt; +use rustix::fs::Uid; +use rustix::process::geteuid; +use rustix::process::getuid; +use rustix::thread::set_thread_res_uid; +use serde_json::Value; +use std::fmt::Display; +use std::fmt::Formatter; +use std::process::Command; +use uzers::os::unix::UserExt; + +fn loginctl_users() -> Result> { + let users: Value = Command::new("loginctl") + .arg("list-sessions") + .arg("--output") + .arg("json") + .run_and_parse_json() + .context("loginctl failed")?; + + users + .as_array() + .context("loginctl output is not an array")? + .iter() + .map(|user_value| { + user_value + .as_object() + .context("user entry is not an object")? + .get("user") + .context("user object doesn't have a user field")? + .as_str() + .context("user name field is not a string") + .map(String::from) + }) + // Artificially add the root user to the list of users as it doesn't appear in loginctl + // list-sessions + .chain(std::iter::once(Ok("root".to_string()))) + .collect::>>() + .context("error parsing users") +} + +struct UidChange { + uid: Uid, + euid: Uid, +} + +impl UidChange { + fn new(change_to_uid: Uid) -> Result { + let (uid, euid) = (getuid(), geteuid()); + set_thread_res_uid(uid, change_to_uid, euid).context("setting effective uid failed")?; + Ok(Self { uid, euid }) + } +} + +impl Drop for UidChange { + fn drop(&mut self) { + set_thread_res_uid(self.uid, self.euid, self.euid).expect("setting effective uid failed"); + } +} + +#[derive(Clone, Debug)] +pub(crate) struct UserKeys { + pub(crate) user: String, + pub(crate) authorized_keys: String, + pub(crate) authorized_keys_path: String, +} + +impl UserKeys { + pub(crate) fn num_keys(&self) -> usize { + self.authorized_keys.lines().count() + } +} + +impl Display for UserKeys { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "User {} ({} authorized keys)", + self.user, + self.num_keys() + ) + } +} + +pub(crate) fn get_all_users_keys() -> Result> { + let loginctl_user_names = loginctl_users().context("enumerate users")?; + + let mut all_users_authorized_keys = Vec::new(); + + for user_name in loginctl_user_names { + let user_info = uzers::get_user_by_name(user_name.as_str()) + .context(format!("user {} not found", user_name))?; + + let home_dir = user_info.home_dir(); + let user_authorized_keys_path = home_dir.join(".ssh/authorized_keys"); + + if !user_authorized_keys_path.exists() { + tracing::debug!( + "Skipping user {} because it doesn't have an SSH authorized_keys file", + user_info.name().to_string_lossy() + ); + continue; + } + + let user_name = user_info + .name() + .to_str() + .context("user name is not valid utf-8")?; + + let user_authorized_keys = { + // Safety: The UID should be valid because we got it from uzers + #[allow(unsafe_code)] + let user_uid = unsafe { Uid::from_raw(user_info.uid()) }; + + // Change the effective uid for this scope, to avoid accidentally reading files we + // shouldn't through symlinks + let _uid_change = UidChange::new(user_uid)?; + + std::fs::read_to_string(&user_authorized_keys_path) + .context("Failed to read user's authorized keys")? + }; + + if user_authorized_keys.trim().is_empty() { + tracing::debug!( + "Skipping user {} because it has an empty SSH authorized_keys file", + user_info.name().to_string_lossy() + ); + continue; + } + + let user_keys = UserKeys { + user: user_name.to_string(), + authorized_keys: user_authorized_keys, + authorized_keys_path: user_authorized_keys_path + .to_str() + .context("user's authorized_keys path is not valid utf-8")? + .to_string(), + }; + + tracing::debug!( + "Found user {} with {} SSH authorized_keys", + user_keys.user, + user_keys.num_keys() + ); + + all_users_authorized_keys.push(user_keys); + } + + Ok(all_users_authorized_keys) +} diff --git a/utils/src/command.rs b/utils/src/command.rs index 9cd4d014..fa20a0d8 100644 --- a/utils/src/command.rs +++ b/utils/src/command.rs @@ -16,6 +16,10 @@ pub trait CommandRunExt { /// Execute the child process. fn run(&mut self) -> Result<()>; + /// Execute the child process. In case of failure, include the command and its arguments in the + /// error context + fn run_with_cmd_context(&mut self) -> Result<()>; + /// Ensure the child does not outlive the parent. fn lifecycle_bind(&mut self) -> &mut Self; @@ -133,6 +137,13 @@ impl CommandRunExt for Command { let output = self.run_get_output()?; serde_json::from_reader(output).map_err(Into::into) } + + fn run_with_cmd_context(&mut self) -> Result<()> { + self.run() + // The [`Debug`] output of command contains a properly shell-escaped commandline + // representation that the user can copy paste into their shell + .context("Failed to run command: {self:#?}") + } } /// Helpers intended for [`tokio::process::Command`].