diff --git a/Cargo.lock b/Cargo.lock index 55e55643..3bd43c38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,7 @@ dependencies = [ "merge", "notify", "rnix", + "rpassword", "serde", "serde_json", "signal-hook", @@ -666,6 +667,27 @@ dependencies = [ "thin-dst", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -859,6 +881,7 @@ dependencies = [ "autocfg", "bytes", "libc", + "memchr", "mio 0.7.6", "num_cpus", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 638e8a27..d8847713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ serde = { version = "1.0.104", features = [ "derive" ] } serde_json = "1.0.48" signal-hook = "0.3" thiserror = "1.0" -tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time" ] } +tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time", "io-util" ] } toml = "0.5" whoami = "0.9.0" yn = "0.1" @@ -33,6 +33,7 @@ yn = "0.1" # 1.45.2 (shipped in nixos-20.09); it requires rustc 1.46.0. See # : smol_str = "=0.1.16" +rpassword = "7.3.1" [lib] diff --git a/examples/system/flake.nix b/examples/system/flake.nix index bcc841c7..d8a19bf2 100644 --- a/examples/system/flake.nix +++ b/examples/system/flake.nix @@ -26,6 +26,7 @@ sshOpts = [ "-p" "2221" ]; hostname = "localhost"; fastConnection = true; + interactiveSudo = true; profiles = { system = { sshUser = "admin"; diff --git a/interface.json b/interface.json index c733f255..a96d1c2d 100644 --- a/interface.json +++ b/interface.json @@ -35,6 +35,9 @@ }, "tempPath": { "type": "string" + }, + "interactiveSudo": { + "type": "boolean" } } }, diff --git a/src/cli.rs b/src/cli.rs index 8ac6f59a..47dc9368 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -103,6 +103,9 @@ pub struct Opts { /// Which sudo command to use. Must accept at least two arguments: user name to execute commands as and the rest is the command to execute #[clap(long)] sudo: Option, + /// Prompt for sudo password during activation. + #[clap(long)] + interactive_sudo: Option, } /// Returns if the available Nix installation supports flakes @@ -538,7 +541,25 @@ async fn run_deploy( log_dir.as_deref(), ); - let deploy_defs = deploy_data.defs()?; + let mut deploy_defs = deploy_data.defs()?; + + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + warn!("Interactive sudo is enabled! Using a sudo password is less secure than correctly configured SSH keys.\nPlease use keys in production environments."); + + if deploy_data.merged_settings.sudo.is_some() { + warn!("Custom sudo commands should be configured to accept password input from stdin when using the 'interactive sudo' option. Deployment may fail if the custom command ignores stdin."); + } else { + // this configures sudo to hide the password prompt and accept input from stdin + // at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root + let original = deploy_defs.sudo.unwrap_or("sudo".to_string()); + deploy_defs.sudo = Some(format!("{} -S -p \"\"", original)); + } + + info!("You will now be prompted for the sudo password for {}.", node.node_settings.hostname); + let sudo_password = rpassword::prompt_password(format!("(sudo for {}) Password: ", node.node_settings.hostname)).unwrap_or("".to_string()); + + deploy_defs.sudo_password = Some(sudo_password); + } parts.push((deploy_flake, deploy_data, deploy_defs)); } @@ -665,6 +686,7 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> { dry_activate: opts.dry_activate, remote_build: opts.remote_build, sudo: opts.sudo, + interactive_sudo: opts.interactive_sudo }; let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?; diff --git a/src/data.rs b/src/data.rs index c507a31a..12b0f01b 100644 --- a/src/data.rs +++ b/src/data.rs @@ -35,6 +35,8 @@ pub struct GenericSettings { pub sudo: Option, #[serde(default,rename(deserialize = "remoteBuild"))] pub remote_build: Option, + #[serde(rename(deserialize = "interactiveSudo"))] + pub interactive_sudo: Option, } #[derive(Deserialize, Debug, Clone)] diff --git a/src/deploy.rs b/src/deploy.rs index a371c181..9f79d646 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -4,12 +4,12 @@ // // SPDX-License-Identifier: MPL-2.0 -use log::{debug, info}; +use log::{debug, info, trace}; use std::path::Path; use thiserror::Error; -use tokio::process::Command; +use tokio::{io::AsyncWriteExt, process::Command}; -use crate::{DeployDataDefsError, ProfileInfo}; +use crate::{DeployDataDefsError, DeployDefs, ProfileInfo}; struct ActivateCommandData<'a> { sudo: &'a Option, @@ -242,6 +242,23 @@ fn test_revoke_command_builder() { ); } +async fn handle_sudo_stdin(ssh_activate_child: &mut tokio::process::Child, deploy_defs: &DeployDefs) -> Result<(), std::io::Error> { + match ssh_activate_child.stdin.as_mut() { + Some(stdin) => { + let _ = stdin.write_all(format!("{}\n",deploy_defs.sudo_password.clone().unwrap_or("".to_string())).as_bytes()).await; + Ok(()) + } + None => { + Err( + std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to open stdin for sudo command", + ) + ) + } + } +} + #[derive(Error, Debug)] pub enum ConfirmProfileError { #[error("Failed to run confirmation command over SSH (the server should roll back): {0}")] @@ -259,7 +276,9 @@ pub async fn confirm_profile( ssh_addr: &str, ) -> Result<(), ConfirmProfileError> { let mut ssh_confirm_command = Command::new("ssh"); - ssh_confirm_command.arg(ssh_addr); + ssh_confirm_command + .arg(ssh_addr) + .stdin(std::process::Stdio::piped()); for ssh_opt in &deploy_data.merged_settings.ssh_opts { ssh_confirm_command.arg(ssh_opt); @@ -277,11 +296,22 @@ pub async fn confirm_profile( confirm_command ); - let ssh_confirm_exit_status = ssh_confirm_command + let mut ssh_confirm_child = ssh_confirm_command .arg(confirm_command) - .status() - .await + .spawn() .map_err(ConfirmProfileError::SSHConfirm)?; + + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + trace!("[confirm] Piping in sudo password"); + handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs) + .await + .map_err(ConfirmProfileError::SSHConfirm)?; + } + + let ssh_confirm_exit_status = ssh_confirm_child + .wait() + .await + .map_err(ConfirmProfileError::SSHConfirm)?; match ssh_confirm_exit_status.code() { Some(0) => (), @@ -308,6 +338,9 @@ pub enum DeployProfileError { #[error("Waiting over SSH resulted in a bad exit code: {0:?}")] SSHWaitExit(Option), + #[error("Failed to pipe to child stdin: {0}")] + SSHActivatePipe(std::io::Error), + #[error("Error confirming deployment: {0}")] Confirm(#[from] ConfirmProfileError), #[error("Deployment data invalid: {0}")] @@ -364,16 +397,29 @@ pub async fn deploy_profile( let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname); let mut ssh_activate_command = Command::new("ssh"); - ssh_activate_command.arg(&ssh_addr); + ssh_activate_command + .arg(&ssh_addr) + .stdin(std::process::Stdio::piped()); for ssh_opt in &deploy_data.merged_settings.ssh_opts { ssh_activate_command.arg(&ssh_opt); } if !magic_rollback || dry_activate || boot { - let ssh_activate_exit_status = ssh_activate_command + let mut ssh_activate_child = ssh_activate_command .arg(self_activate_command) - .status() + .spawn() + .map_err(DeployProfileError::SSHSpawnActivate)?; + + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + trace!("[activate] Piping in sudo password"); + handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) + .await + .map_err(DeployProfileError::SSHActivatePipe)?; + } + + let ssh_activate_exit_status = ssh_activate_child + .wait() .await .map_err(DeployProfileError::SSHActivate)?; @@ -401,16 +447,25 @@ pub async fn deploy_profile( debug!("Constructed wait command: {}", self_wait_command); - let ssh_activate = ssh_activate_command + let mut ssh_activate_child = ssh_activate_command .arg(self_activate_command) .spawn() .map_err(DeployProfileError::SSHSpawnActivate)?; + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + trace!("[activate] Piping in sudo password"); + handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) + .await + .map_err(DeployProfileError::SSHActivatePipe)?; + } + info!("Creating activation waiter"); let mut ssh_wait_command = Command::new("ssh"); - ssh_wait_command.arg(&ssh_addr); - + ssh_wait_command + .arg(&ssh_addr) + .stdin(std::process::Stdio::piped()); + for ssh_opt in &deploy_data.merged_settings.ssh_opts { ssh_wait_command.arg(ssh_opt); } @@ -419,7 +474,7 @@ pub async fn deploy_profile( let (send_activated, recv_activated) = tokio::sync::oneshot::channel(); let thread = tokio::spawn(async move { - let o = ssh_activate.wait_with_output().await; + let o = ssh_activate_child.wait_with_output().await; let maybe_err = match o { Err(x) => Some(DeployProfileError::SSHActivate(x)), @@ -435,8 +490,21 @@ pub async fn deploy_profile( send_activated.send(()).unwrap(); }); + + let mut ssh_wait_child = ssh_wait_command + .arg(self_wait_command) + .spawn() + .map_err(DeployProfileError::SSHWait)?; + + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + trace!("[wait] Piping in sudo password"); + handle_sudo_stdin(&mut ssh_wait_child, deploy_defs) + .await + .map_err(DeployProfileError::SSHActivatePipe)?; + } + tokio::select! { - x = ssh_wait_command.arg(self_wait_command).status() => { + x = ssh_wait_child.wait() => { debug!("Wait command ended"); match x.map_err(DeployProfileError::SSHWait)?.code() { Some(0) => (), @@ -498,18 +566,27 @@ pub async fn revoke( let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname); let mut ssh_activate_command = Command::new("ssh"); - ssh_activate_command.arg(&ssh_addr); + ssh_activate_command + .arg(&ssh_addr) + .stdin(std::process::Stdio::piped()); for ssh_opt in &deploy_data.merged_settings.ssh_opts { ssh_activate_command.arg(&ssh_opt); } - let ssh_revoke = ssh_activate_command + let mut ssh_revoke_child = ssh_activate_command .arg(self_revoke_command) .spawn() .map_err(RevokeProfileError::SSHSpawnRevoke)?; - let result = ssh_revoke.wait_with_output().await; + if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + trace!("[revoke] Piping in sudo password"); + handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs) + .await + .map_err(RevokeProfileError::SSHRevoke)?; + } + + let result = ssh_revoke_child.wait_with_output().await; match result { Err(x) => Err(RevokeProfileError::SSHRevoke(x)), diff --git a/src/lib.rs b/src/lib.rs index 663e26eb..61fac6a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,7 @@ pub struct CmdOverrides { pub confirm_timeout: Option, pub activation_timeout: Option, pub sudo: Option, + pub interactive_sudo: Option, pub dry_activate: bool, pub remote_build: bool, } @@ -334,6 +335,7 @@ pub struct DeployDefs { pub ssh_user: String, pub profile_user: String, pub sudo: Option, + pub sudo_password: Option, } enum ProfileInfo { ProfilePath { @@ -369,6 +371,7 @@ impl<'a> DeployData<'a> { ssh_user, profile_user, sudo, + sudo_password: None, }) } @@ -448,6 +451,9 @@ pub fn make_deploy_data<'a, 's>( if let Some(activation_timeout) = cmd_overrides.activation_timeout { merged_settings.activation_timeout = Some(activation_timeout); } + if let Some(interactive_sudo) = cmd_overrides.interactive_sudo { + merged_settings.interactive_sudo = Some(interactive_sudo); + } DeployData { node_name,