Skip to content

Commit bb22a66

Browse files
NotAShelfr0chd
authored andcommitted
Merge branch 'nix-community:master' into notifications
2 parents 403e82d + 0dfb748 commit bb22a66

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};
@@ -18,12 +20,37 @@ use crate::{
1820
interface::NixBuildPassthroughArgs,
1921
};
2022

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

@@ -435,6 +526,7 @@ impl Command {
435526
cmd.stderr(Redirection::None).stdout(Redirection::None)
436527
},
437528
self.ssh.as_deref(),
529+
sudo_password.as_ref(),
438530
);
439531

440532
if let Some(m) = &self.message {
@@ -1063,7 +1155,7 @@ mod tests {
10631155
#[test]
10641156
fn test_ssh_wrap_with_ssh() {
10651157
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1066-
let wrapped = ssh_wrap(cmd, Some("user@host"));
1158+
let wrapped = ssh_wrap(cmd, Some("user@host"), None);
10671159

10681160
let cmdline = wrapped.to_cmdline_lossy();
10691161
assert!(cmdline.starts_with("ssh"));
@@ -1074,12 +1166,24 @@ mod tests {
10741166
#[test]
10751167
fn test_ssh_wrap_without_ssh() {
10761168
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1077-
let wrapped = ssh_wrap(cmd.clone(), None);
1169+
let wrapped = ssh_wrap(cmd.clone(), None, None);
10781170

10791171
// Should return the original command unchanged
10801172
assert_eq!(wrapped.to_cmdline_lossy(), cmd.to_cmdline_lossy());
10811173
}
10821174

1175+
#[test]
1176+
fn test_ssh_wrap_with_password() {
1177+
let cmd = subprocess::Exec::cmd("echo").arg("hello");
1178+
let password = SecretString::new("testpass".to_string());
1179+
let wrapped = ssh_wrap(cmd, Some("user@host"), Some(&password));
1180+
1181+
let cmdline = wrapped.to_cmdline_lossy();
1182+
assert!(cmdline.starts_with("ssh"));
1183+
assert!(cmdline.contains("-T"));
1184+
assert!(cmdline.contains("user@host"));
1185+
}
1186+
10831187
#[test]
10841188
#[serial]
10851189
fn test_apply_env_to_exec() {

0 commit comments

Comments
 (0)