Skip to content

Commit 133de1d

Browse files
committed
chore: implement sudo_password decryption with sops
1 parent 6bc76b8 commit 133de1d

File tree

4 files changed

+155
-17
lines changed

4 files changed

+155
-17
lines changed

interface.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
},
3939
"interactiveSudo": {
4040
"type": "boolean"
41+
},
42+
"sudoFile": {
43+
"type": "path"
44+
},
45+
"sudoSecret": {
46+
"type": "string"
4147
}
4248
}
4349
},

src/cli.rs

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use std::collections::HashMap;
77
use std::io::{stdin, stdout, Write};
8+
use std::str::Utf8Error;
89

910
use clap::{ArgMatches, Parser, FromArgMatches};
1011

@@ -17,6 +18,7 @@ use serde::Serialize;
1718
use std::path::PathBuf;
1819
use std::process::Stdio;
1920
use thiserror::Error;
21+
use tokio::fs::try_exists;
2022
use tokio::process::Command;
2123

2224
/// Simple Rust rewrite of a simple Nix Flake deployment tool
@@ -404,7 +406,9 @@ pub enum RunDeployError {
404406
#[error("Failed to revoke profile for node {0}: {1}")]
405407
RevokeProfile(String, deploy::deploy::RevokeProfileError),
406408
#[error("Deployment to node {0} failed, rolled back to previous generation")]
407-
Rollback(String)
409+
Rollback(String),
410+
#[error("Failed to get the password from sops: {0}")]
411+
Sops(#[from] deploy::cli::SopsError),
408412
}
409413

410414
type ToDeploy<'a> = Vec<(
@@ -548,21 +552,103 @@ async fn run_deploy(
548552

549553
let mut deploy_defs = deploy_data.defs()?;
550554

551-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
555+
if deploy_data.merged_settings.sudo.is_some()
556+
&& (deploy_data.merged_settings.interactive_sudo.is_some()
557+
|| deploy_data.merged_settings.sudo_secret.is_some())
558+
{
559+
warn!("Custom sudo commands should be configured to accept password input from stdin when using the 'interactive sudo' or 'password File' option. Deployment may fail if the custom command ignores stdin.");
560+
} else {
561+
// this configures sudo to hide the password prompt and accept input from stdin
562+
// at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root
563+
let original = deploy_defs.sudo.unwrap_or("sudo".to_string());
564+
deploy_defs.sudo = Some(format!("{} -S -p \"\"", original));
565+
}
566+
567+
if deploy_data
568+
.merged_settings
569+
.interactive_sudo
570+
.unwrap_or(false)
571+
{
552572
warn!("Interactive sudo is enabled! Using a sudo password is less secure than correctly configured SSH keys.\nPlease use keys in production environments.");
553573

554-
if deploy_data.merged_settings.sudo.is_some() {
555-
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.");
556-
} else {
557-
// this configures sudo to hide the password prompt and accept input from stdin
558-
// at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root
559-
let original = deploy_defs.sudo.unwrap_or("sudo".to_string());
560-
deploy_defs.sudo = Some(format!("{} -S -p \"\"", original));
561-
}
574+
info!(
575+
"You will now be prompted for the sudo password for {}.",
576+
node.node_settings.hostname
577+
);
578+
579+
let sudo_password = rpassword::prompt_password(format!(
580+
"(sudo for {}) Password: ",
581+
node.node_settings.hostname
582+
))
583+
.unwrap_or("".to_string());
562584

563-
info!("You will now be prompted for the sudo password for {}.", node.node_settings.hostname);
564-
let sudo_password = rpassword::prompt_password(format!("(sudo for {}) Password: ", node.node_settings.hostname)).unwrap_or("".to_string());
585+
deploy_defs.sudo_password = Some(sudo_password);
586+
} else if deploy_data.merged_settings.sudo_file.is_some()
587+
&& deploy_data.merged_settings.sudo_secret.is_some()
588+
{
589+
// SAFETY: we already checked if it is some
590+
let path = deploy_data.merged_settings.sudo_file.clone().unwrap();
591+
let key = deploy_data.merged_settings.sudo_secret.clone().unwrap();
592+
593+
if !try_exists(&path).await.unwrap() {
594+
return Err(RunDeployError::Sops(SopsError::SopsFileNotFound(format!(
595+
"{path:?} not found"
596+
))));
597+
}
565598

599+
// We deserialze to json
600+
let out = Command::new("sops")
601+
.arg("--output-type")
602+
.arg("json")
603+
.arg("-d")
604+
.arg(&path)
605+
.output()
606+
.await
607+
.map_err(|err| {
608+
RunDeployError::Sops(SopsError::SopsFailedDecryption(
609+
path.to_string_lossy().into(),
610+
err,
611+
))
612+
})?;
613+
614+
let conv_out = std::str::from_utf8(&out.stdout)
615+
.map_err(|err| RunDeployError::Sops(SopsError::SopsCannotConvert(err)))?;
616+
617+
let mut m: serde_json::Map<String, serde_json::Value> = serde_json::from_str(conv_out)
618+
.map_err(|err| RunDeployError::Sops(SopsError::SerdeDeserialize(err)))?;
619+
620+
let mut sudo_password = String::new();
621+
622+
// We support nested keys like a/b/c
623+
for i in key.split('/') {
624+
match m.get(i) {
625+
Some(v) => match v {
626+
serde_json::Value::String(s) => {
627+
sudo_password = s.into();
628+
}
629+
serde_json::Value::Bool(b) => {
630+
sudo_password = b.to_string();
631+
}
632+
serde_json::Value::Number(n) => {
633+
sudo_password = n.to_string();
634+
}
635+
serde_json::Value::Object(map) => {
636+
m = map.clone();
637+
}
638+
_ => {
639+
return Err(RunDeployError::Sops(SopsError::SerdeUnexpectedType(
640+
"We dont handle Arrays, Bools, None, Numbers".into(),
641+
)));
642+
}
643+
},
644+
None => {
645+
return Err(RunDeployError::Sops(SopsError::SopsKeyNotFound(format!(
646+
"Did not find {} in Map",
647+
i
648+
))));
649+
}
650+
}
651+
}
566652
deploy_defs.sudo_password = Some(sudo_password);
567653
}
568654

@@ -639,6 +725,22 @@ async fn run_deploy(
639725
Ok(())
640726
}
641727

728+
#[derive(Error, Debug)]
729+
pub enum SopsError {
730+
#[error("Failed to decrypt file {0}: {1}")]
731+
SopsFailedDecryption(String, std::io::Error),
732+
#[error("Failed to find sops file: {0}")]
733+
SopsFileNotFound(String),
734+
#[error("Failed to convert the output of sops to a str: {0}")]
735+
SopsCannotConvert(Utf8Error),
736+
#[error("Failed to deserialize: {0}")]
737+
SerdeDeserialize(serde_json::Error),
738+
#[error("Error unexpected type: {0}")]
739+
SerdeUnexpectedType(String),
740+
#[error("Failed to find key: {0}")]
741+
SopsKeyNotFound(String),
742+
}
743+
642744
#[derive(Error, Debug)]
643745
pub enum RunError {
644746
#[error("Failed to deploy profile: {0}")]

src/data.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ pub struct GenericSettings {
3737
pub remote_build: Option<bool>,
3838
#[serde(rename(deserialize = "interactiveSudo"))]
3939
pub interactive_sudo: Option<bool>,
40+
// sops integration for secrets
41+
#[serde(rename(deserialize = "sudoFile"))]
42+
pub sudo_file: Option<PathBuf>,
43+
#[serde(rename(deserialize = "sudoSecret"))]
44+
pub sudo_secret: Option<String>,
4045
}
4146

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

src/deploy.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,12 @@ pub async fn confirm_profile(
301301
.spawn()
302302
.map_err(ConfirmProfileError::SSHConfirm)?;
303303

304-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
304+
if deploy_data
305+
.merged_settings
306+
.interactive_sudo
307+
.unwrap_or(false)
308+
|| deploy_data.merged_settings.sudo_secret.is_some()
309+
{
305310
trace!("[confirm] Piping in sudo password");
306311
handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs)
307312
.await
@@ -413,7 +418,12 @@ pub async fn deploy_profile(
413418
.spawn()
414419
.map_err(DeployProfileError::SSHSpawnActivate)?;
415420

416-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
421+
if deploy_data
422+
.merged_settings
423+
.interactive_sudo
424+
.unwrap_or(false)
425+
|| deploy_data.merged_settings.sudo_secret.is_some()
426+
{
417427
trace!("[activate] Piping in sudo password");
418428
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
419429
.await
@@ -454,7 +464,12 @@ pub async fn deploy_profile(
454464
.spawn()
455465
.map_err(DeployProfileError::SSHSpawnActivate)?;
456466

457-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
467+
if deploy_data
468+
.merged_settings
469+
.interactive_sudo
470+
.unwrap_or(false)
471+
|| deploy_data.merged_settings.sudo_secret.is_some()
472+
{
458473
trace!("[activate] Piping in sudo password");
459474
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
460475
.await
@@ -498,7 +513,12 @@ pub async fn deploy_profile(
498513
.spawn()
499514
.map_err(DeployProfileError::SSHWait)?;
500515

501-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
516+
if deploy_data
517+
.merged_settings
518+
.interactive_sudo
519+
.unwrap_or(false)
520+
|| deploy_data.merged_settings.sudo_secret.is_some()
521+
{
502522
trace!("[wait] Piping in sudo password");
503523
handle_sudo_stdin(&mut ssh_wait_child, deploy_defs)
504524
.await
@@ -581,7 +601,12 @@ pub async fn revoke(
581601
.spawn()
582602
.map_err(RevokeProfileError::SSHSpawnRevoke)?;
583603

584-
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
604+
if deploy_data
605+
.merged_settings
606+
.interactive_sudo
607+
.unwrap_or(false)
608+
|| deploy_data.merged_settings.sudo_secret.is_some()
609+
{
585610
trace!("[revoke] Piping in sudo password");
586611
handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs)
587612
.await

0 commit comments

Comments
 (0)