Skip to content

Commit 453a5f1

Browse files
NotAShelfr0chd
authored andcommitted
Merge branch 'nix-community:master' into notifications
2 parents ad01b9a + 0dfb748 commit 453a5f1

File tree

4 files changed

+125
-6
lines changed

4 files changed

+125
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ functionality, under the "Removed" section.
4949
- Nh now supports alternative privilege escalation methods. Namely `doas`,
5050
`run0` and a fallback `pkexec` strategies will be attempted if the system does
5151
not use `sudo`.
52+
- Nh will correctly prompt you for your `sudo` password while deploying
53+
remotely. This helps mitigate the need to allow password-less `sudo` on
54+
the target host to deploy remotely.
5255

5356
### Fixed
5457

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ reqwest = { default-features = false, features = [
4646
"blocking",
4747
"json",
4848
], version = "0.12.23" }
49+
secrecy = { version = "0.8.0", features = [ "serde" ] }
4950
semver = "1.0.26"
5051
serde = { features = [ "derive" ], version = "1.0.219" }
5152
serde_json = "1.0.143"

src/commands.rs

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ use std::{
22
collections::HashMap,
33
ffi::{OsStr, OsString},
44
path::PathBuf,
5+
sync::{Mutex, OnceLock},
56
};
67

78
use color_eyre::{
89
Result,
910
eyre::{self, Context, bail},
1011
};
12+
use secrecy::{ExposeSecret, SecretString};
1113
use subprocess::{Exec, ExitStatus, Redirection};
1214
use thiserror::Error;
1315
use tracing::{debug, info, warn};
@@ -19,12 +21,37 @@ use crate::{
1921
notify::NotificationSender,
2022
};
2123

22-
fn ssh_wrap(cmd: Exec, ssh: Option<&str>) -> Exec {
24+
static PASSWORD_CACHE: OnceLock<Mutex<HashMap<String, SecretString>>> =
25+
OnceLock::new();
26+
27+
fn get_cached_password(host: &str) -> Option<SecretString> {
28+
let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
29+
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
30+
guard.get(host).cloned()
31+
}
32+
33+
fn cache_password(host: &str, password: SecretString) {
34+
let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
35+
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
36+
guard.insert(host.to_string(), password);
37+
}
38+
39+
fn ssh_wrap(
40+
cmd: Exec,
41+
ssh: Option<&str>,
42+
password: Option<&SecretString>,
43+
) -> Exec {
2344
if let Some(ssh) = ssh {
24-
Exec::cmd("ssh")
45+
let mut ssh_cmd = Exec::cmd("ssh")
2546
.arg("-T")
2647
.arg(ssh)
27-
.stdin(cmd.to_cmdline_lossy().as_str())
48+
.arg(cmd.to_cmdline_lossy());
49+
50+
if let Some(pwd) = password {
51+
ssh_cmd = ssh_cmd.stdin(format!("{}\n", pwd.expose_secret()).as_str());
52+
}
53+
54+
ssh_cmd
2855
} else {
2956
cmd
3057
}
@@ -422,9 +449,73 @@ impl Command {
422449
///
423450
/// Panics if the command result is unexpectedly None.
424451
pub fn run(&self) -> Result<()> {
425-
let cmd = if self.elevate.is_some() {
452+
// Prompt for sudo password if needed for remote deployment
453+
// FIXME: this implementation only covers Sudo. I *think* doas and run0 are
454+
// able to read from stdin, but needs to be tested and possibly
455+
// mitigated.
456+
let sudo_password = if self.ssh.is_some() && self.elevate.is_some() {
457+
let host = self.ssh.as_ref().unwrap();
458+
if let Some(cached_password) = get_cached_password(host) {
459+
Some(cached_password)
460+
} else {
461+
let password =
462+
inquire::Password::new(&format!("[sudo] password for {}:", host))
463+
.without_confirmation()
464+
.prompt()
465+
.context("Failed to read sudo password")?;
466+
let secret_password = SecretString::new(password);
467+
cache_password(host, secret_password.clone());
468+
Some(secret_password)
469+
}
470+
} else {
471+
None
472+
};
473+
474+
let cmd = if self.elevate.is_some() && self.ssh.is_none() {
475+
// Local elevation
426476
self.build_sudo_cmd()?.arg(&self.command).args(&self.args)
477+
} else if self.elevate.is_some() && self.ssh.is_some() {
478+
// Build elevation command
479+
let elevation_program = self
480+
.elevate
481+
.as_ref()
482+
.unwrap()
483+
.resolve()
484+
.context("Failed to resolve elevation program")?;
485+
486+
let program_name = elevation_program
487+
.file_name()
488+
.and_then(|name| name.to_str())
489+
.ok_or_else(|| {
490+
eyre::eyre!("Failed to determine elevation program name")
491+
})?;
492+
493+
let mut elev_cmd = Exec::cmd(&elevation_program);
494+
495+
// Add program-specific arguments
496+
if program_name == "sudo" {
497+
elev_cmd = elev_cmd.arg("--prompt=").arg("--stdin");
498+
}
499+
500+
// Add env command to handle environment variables
501+
elev_cmd = elev_cmd.arg("env");
502+
for (key, action) in &self.env_vars {
503+
match action {
504+
EnvAction::Set(value) => {
505+
elev_cmd = elev_cmd.arg(format!("{}={}", key, value));
506+
},
507+
EnvAction::Preserve => {
508+
if let Ok(value) = std::env::var(key) {
509+
elev_cmd = elev_cmd.arg(format!("{}={}", key, value));
510+
}
511+
},
512+
_ => {},
513+
}
514+
}
515+
516+
elev_cmd.arg(&self.command).args(&self.args)
427517
} else {
518+
// No elevation
428519
self.apply_env_to_exec(Exec::cmd(&self.command).args(&self.args))
429520
};
430521

@@ -436,6 +527,7 @@ impl Command {
436527
cmd.stderr(Redirection::None).stdout(Redirection::None)
437528
},
438529
self.ssh.as_deref(),
530+
sudo_password.as_ref(),
439531
);
440532

441533
if let Some(m) = &self.message {
@@ -1078,7 +1170,7 @@ mod tests {
10781170
#[test]
10791171
fn test_ssh_wrap_with_ssh() {
10801172
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1081-
let wrapped = ssh_wrap(cmd, Some("user@host"));
1173+
let wrapped = ssh_wrap(cmd, Some("user@host"), None);
10821174

10831175
let cmdline = wrapped.to_cmdline_lossy();
10841176
assert!(cmdline.starts_with("ssh"));
@@ -1089,12 +1181,24 @@ mod tests {
10891181
#[test]
10901182
fn test_ssh_wrap_without_ssh() {
10911183
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1092-
let wrapped = ssh_wrap(cmd.clone(), None);
1184+
let wrapped = ssh_wrap(cmd.clone(), None, None);
10931185

10941186
// Should return the original command unchanged
10951187
assert_eq!(wrapped.to_cmdline_lossy(), cmd.to_cmdline_lossy());
10961188
}
10971189

1190+
#[test]
1191+
fn test_ssh_wrap_with_password() {
1192+
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1193+
let password = SecretString::new("testpass".to_string());
1194+
let wrapped = ssh_wrap(cmd, Some("user@host"), Some(&password));
1195+
1196+
let cmdline = wrapped.to_cmdline_lossy();
1197+
assert!(cmdline.starts_with("ssh"));
1198+
assert!(cmdline.contains("-T"));
1199+
assert!(cmdline.contains("user@host"));
1200+
}
1201+
10981202
#[test]
10991203
#[serial]
11001204
fn test_apply_env_to_exec() {

0 commit comments

Comments
 (0)