Skip to content

Commit 0a01877

Browse files
authored
Merge pull request #257 from n-hass/feature/interactive-sudo
Add support for entering sudo password interactively
2 parents 1776009 + 5f694ef commit 0a01877

File tree

8 files changed

+155
-20
lines changed

8 files changed

+155
-20
lines changed

Cargo.lock

Lines changed: 23 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ serde = { version = "1.0.104", features = [ "derive" ] }
2424
serde_json = "1.0.48"
2525
signal-hook = "0.3"
2626
thiserror = "1.0"
27-
tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time" ] }
27+
tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time", "io-util" ] }
2828
toml = "0.5"
2929
whoami = "0.9.0"
3030
yn = "0.1"
@@ -33,6 +33,7 @@ yn = "0.1"
3333
# 1.45.2 (shipped in nixos-20.09); it requires rustc 1.46.0. See
3434
# <https://github.com/serokell/deploy-rs/issues/27>:
3535
smol_str = "=0.1.16"
36+
rpassword = "7.3.1"
3637

3738

3839
[lib]

examples/system/flake.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
sshOpts = [ "-p" "2221" ];
2727
hostname = "localhost";
2828
fastConnection = true;
29+
interactiveSudo = true;
2930
profiles = {
3031
system = {
3132
sshUser = "admin";

interface.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
},
3636
"tempPath": {
3737
"type": "string"
38+
},
39+
"interactiveSudo": {
40+
"type": "boolean"
3841
}
3942
}
4043
},

src/cli.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub struct Opts {
103103
/// 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
104104
#[clap(long)]
105105
sudo: Option<String>,
106+
/// Prompt for sudo password during activation.
107+
#[clap(long)]
108+
interactive_sudo: Option<bool>,
106109
}
107110

108111
/// Returns if the available Nix installation supports flakes
@@ -538,7 +541,25 @@ async fn run_deploy(
538541
log_dir.as_deref(),
539542
);
540543

541-
let deploy_defs = deploy_data.defs()?;
544+
let mut deploy_defs = deploy_data.defs()?;
545+
546+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
547+
warn!("Interactive sudo is enabled! Using a sudo password is less secure than correctly configured SSH keys.\nPlease use keys in production environments.");
548+
549+
if deploy_data.merged_settings.sudo.is_some() {
550+
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.");
551+
} else {
552+
// this configures sudo to hide the password prompt and accept input from stdin
553+
// at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root
554+
let original = deploy_defs.sudo.unwrap_or("sudo".to_string());
555+
deploy_defs.sudo = Some(format!("{} -S -p \"\"", original));
556+
}
557+
558+
info!("You will now be prompted for the sudo password for {}.", node.node_settings.hostname);
559+
let sudo_password = rpassword::prompt_password(format!("(sudo for {}) Password: ", node.node_settings.hostname)).unwrap_or("".to_string());
560+
561+
deploy_defs.sudo_password = Some(sudo_password);
562+
}
542563

543564
parts.push((deploy_flake, deploy_data, deploy_defs));
544565
}
@@ -665,6 +686,7 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> {
665686
dry_activate: opts.dry_activate,
666687
remote_build: opts.remote_build,
667688
sudo: opts.sudo,
689+
interactive_sudo: opts.interactive_sudo
668690
};
669691

670692
let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?;

src/data.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct GenericSettings {
3535
pub sudo: Option<String>,
3636
#[serde(default,rename(deserialize = "remoteBuild"))]
3737
pub remote_build: Option<bool>,
38+
#[serde(rename(deserialize = "interactiveSudo"))]
39+
pub interactive_sudo: Option<bool>,
3840
}
3941

4042
#[derive(Deserialize, Debug, Clone)]

src/deploy.rs

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
//
55
// SPDX-License-Identifier: MPL-2.0
66

7-
use log::{debug, info};
7+
use log::{debug, info, trace};
88
use std::path::Path;
99
use thiserror::Error;
10-
use tokio::process::Command;
10+
use tokio::{io::AsyncWriteExt, process::Command};
1111

12-
use crate::{DeployDataDefsError, ProfileInfo};
12+
use crate::{DeployDataDefsError, DeployDefs, ProfileInfo};
1313

1414
struct ActivateCommandData<'a> {
1515
sudo: &'a Option<String>,
@@ -242,6 +242,23 @@ fn test_revoke_command_builder() {
242242
);
243243
}
244244

245+
async fn handle_sudo_stdin(ssh_activate_child: &mut tokio::process::Child, deploy_defs: &DeployDefs) -> Result<(), std::io::Error> {
246+
match ssh_activate_child.stdin.as_mut() {
247+
Some(stdin) => {
248+
let _ = stdin.write_all(format!("{}\n",deploy_defs.sudo_password.clone().unwrap_or("".to_string())).as_bytes()).await;
249+
Ok(())
250+
}
251+
None => {
252+
Err(
253+
std::io::Error::new(
254+
std::io::ErrorKind::Other,
255+
"Failed to open stdin for sudo command",
256+
)
257+
)
258+
}
259+
}
260+
}
261+
245262
#[derive(Error, Debug)]
246263
pub enum ConfirmProfileError {
247264
#[error("Failed to run confirmation command over SSH (the server should roll back): {0}")]
@@ -259,7 +276,9 @@ pub async fn confirm_profile(
259276
ssh_addr: &str,
260277
) -> Result<(), ConfirmProfileError> {
261278
let mut ssh_confirm_command = Command::new("ssh");
262-
ssh_confirm_command.arg(ssh_addr);
279+
ssh_confirm_command
280+
.arg(ssh_addr)
281+
.stdin(std::process::Stdio::piped());
263282

264283
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
265284
ssh_confirm_command.arg(ssh_opt);
@@ -277,11 +296,22 @@ pub async fn confirm_profile(
277296
confirm_command
278297
);
279298

280-
let ssh_confirm_exit_status = ssh_confirm_command
299+
let mut ssh_confirm_child = ssh_confirm_command
281300
.arg(confirm_command)
282-
.status()
283-
.await
301+
.spawn()
284302
.map_err(ConfirmProfileError::SSHConfirm)?;
303+
304+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
305+
trace!("[confirm] Piping in sudo password");
306+
handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs)
307+
.await
308+
.map_err(ConfirmProfileError::SSHConfirm)?;
309+
}
310+
311+
let ssh_confirm_exit_status = ssh_confirm_child
312+
.wait()
313+
.await
314+
.map_err(ConfirmProfileError::SSHConfirm)?;
285315

286316
match ssh_confirm_exit_status.code() {
287317
Some(0) => (),
@@ -308,6 +338,9 @@ pub enum DeployProfileError {
308338
#[error("Waiting over SSH resulted in a bad exit code: {0:?}")]
309339
SSHWaitExit(Option<i32>),
310340

341+
#[error("Failed to pipe to child stdin: {0}")]
342+
SSHActivatePipe(std::io::Error),
343+
311344
#[error("Error confirming deployment: {0}")]
312345
Confirm(#[from] ConfirmProfileError),
313346
#[error("Deployment data invalid: {0}")]
@@ -364,16 +397,29 @@ pub async fn deploy_profile(
364397
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
365398

366399
let mut ssh_activate_command = Command::new("ssh");
367-
ssh_activate_command.arg(&ssh_addr);
400+
ssh_activate_command
401+
.arg(&ssh_addr)
402+
.stdin(std::process::Stdio::piped());
368403

369404
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
370405
ssh_activate_command.arg(&ssh_opt);
371406
}
372407

373408
if !magic_rollback || dry_activate || boot {
374-
let ssh_activate_exit_status = ssh_activate_command
409+
let mut ssh_activate_child = ssh_activate_command
375410
.arg(self_activate_command)
376-
.status()
411+
.spawn()
412+
.map_err(DeployProfileError::SSHSpawnActivate)?;
413+
414+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
415+
trace!("[activate] Piping in sudo password");
416+
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
417+
.await
418+
.map_err(DeployProfileError::SSHActivatePipe)?;
419+
}
420+
421+
let ssh_activate_exit_status = ssh_activate_child
422+
.wait()
377423
.await
378424
.map_err(DeployProfileError::SSHActivate)?;
379425

@@ -401,16 +447,25 @@ pub async fn deploy_profile(
401447

402448
debug!("Constructed wait command: {}", self_wait_command);
403449

404-
let ssh_activate = ssh_activate_command
450+
let mut ssh_activate_child = ssh_activate_command
405451
.arg(self_activate_command)
406452
.spawn()
407453
.map_err(DeployProfileError::SSHSpawnActivate)?;
408454

455+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
456+
trace!("[activate] Piping in sudo password");
457+
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
458+
.await
459+
.map_err(DeployProfileError::SSHActivatePipe)?;
460+
}
461+
409462
info!("Creating activation waiter");
410463

411464
let mut ssh_wait_command = Command::new("ssh");
412-
ssh_wait_command.arg(&ssh_addr);
413-
465+
ssh_wait_command
466+
.arg(&ssh_addr)
467+
.stdin(std::process::Stdio::piped());
468+
414469
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
415470
ssh_wait_command.arg(ssh_opt);
416471
}
@@ -419,7 +474,7 @@ pub async fn deploy_profile(
419474
let (send_activated, recv_activated) = tokio::sync::oneshot::channel();
420475

421476
let thread = tokio::spawn(async move {
422-
let o = ssh_activate.wait_with_output().await;
477+
let o = ssh_activate_child.wait_with_output().await;
423478

424479
let maybe_err = match o {
425480
Err(x) => Some(DeployProfileError::SSHActivate(x)),
@@ -435,8 +490,21 @@ pub async fn deploy_profile(
435490

436491
send_activated.send(()).unwrap();
437492
});
493+
494+
let mut ssh_wait_child = ssh_wait_command
495+
.arg(self_wait_command)
496+
.spawn()
497+
.map_err(DeployProfileError::SSHWait)?;
498+
499+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
500+
trace!("[wait] Piping in sudo password");
501+
handle_sudo_stdin(&mut ssh_wait_child, deploy_defs)
502+
.await
503+
.map_err(DeployProfileError::SSHActivatePipe)?;
504+
}
505+
438506
tokio::select! {
439-
x = ssh_wait_command.arg(self_wait_command).status() => {
507+
x = ssh_wait_child.wait() => {
440508
debug!("Wait command ended");
441509
match x.map_err(DeployProfileError::SSHWait)?.code() {
442510
Some(0) => (),
@@ -498,18 +566,27 @@ pub async fn revoke(
498566
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
499567

500568
let mut ssh_activate_command = Command::new("ssh");
501-
ssh_activate_command.arg(&ssh_addr);
569+
ssh_activate_command
570+
.arg(&ssh_addr)
571+
.stdin(std::process::Stdio::piped());
502572

503573
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
504574
ssh_activate_command.arg(&ssh_opt);
505575
}
506576

507-
let ssh_revoke = ssh_activate_command
577+
let mut ssh_revoke_child = ssh_activate_command
508578
.arg(self_revoke_command)
509579
.spawn()
510580
.map_err(RevokeProfileError::SSHSpawnRevoke)?;
511581

512-
let result = ssh_revoke.wait_with_output().await;
582+
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
583+
trace!("[revoke] Piping in sudo password");
584+
handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs)
585+
.await
586+
.map_err(RevokeProfileError::SSHRevoke)?;
587+
}
588+
589+
let result = ssh_revoke_child.wait_with_output().await;
513590

514591
match result {
515592
Err(x) => Err(RevokeProfileError::SSHRevoke(x)),

src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ pub struct CmdOverrides {
165165
pub confirm_timeout: Option<u16>,
166166
pub activation_timeout: Option<u16>,
167167
pub sudo: Option<String>,
168+
pub interactive_sudo: Option<bool>,
168169
pub dry_activate: bool,
169170
pub remote_build: bool,
170171
}
@@ -334,6 +335,7 @@ pub struct DeployDefs {
334335
pub ssh_user: String,
335336
pub profile_user: String,
336337
pub sudo: Option<String>,
338+
pub sudo_password: Option<String>,
337339
}
338340
enum ProfileInfo {
339341
ProfilePath {
@@ -369,6 +371,7 @@ impl<'a> DeployData<'a> {
369371
ssh_user,
370372
profile_user,
371373
sudo,
374+
sudo_password: None,
372375
})
373376
}
374377

@@ -448,6 +451,9 @@ pub fn make_deploy_data<'a, 's>(
448451
if let Some(activation_timeout) = cmd_overrides.activation_timeout {
449452
merged_settings.activation_timeout = Some(activation_timeout);
450453
}
454+
if let Some(interactive_sudo) = cmd_overrides.interactive_sudo {
455+
merged_settings.interactive_sudo = Some(interactive_sudo);
456+
}
451457

452458
DeployData {
453459
node_name,

0 commit comments

Comments
 (0)