Skip to main content

hardpass/
ssh.rs

1use std::path::Path;
2use std::process::{ExitStatus, Stdio};
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result, bail};
6use tokio::process::Command;
7use tokio::time::sleep;
8
9use crate::lock::{lock_file, sibling_lock_path};
10use crate::state::SshConfig;
11
12#[derive(Debug)]
13pub struct ExecOutput {
14    pub status: ExitStatus,
15    pub stdout: String,
16    pub stderr: String,
17}
18
19pub async fn ensure_ssh_key(identity_file: &Path) -> Result<String> {
20    let _lock = lock_file(sibling_lock_path(identity_file)).await?;
21    if !identity_file.is_file() || !identity_file.with_extension("pub").is_file() {
22        if let Some(parent) = identity_file.parent() {
23            tokio::fs::create_dir_all(parent).await?;
24        }
25        let output = Command::new("ssh-keygen")
26            .arg("-q")
27            .arg("-t")
28            .arg("ed25519")
29            .arg("-N")
30            .arg("")
31            .arg("-f")
32            .arg(identity_file)
33            .arg("-C")
34            .arg("hardpass")
35            .output()
36            .await
37            .context("run ssh-keygen")?;
38        if !output.status.success() {
39            bail!(
40                "ssh-keygen failed: {}",
41                String::from_utf8_lossy(&output.stderr).trim()
42            );
43        }
44    }
45    let public_key = tokio::fs::read_to_string(identity_file.with_extension("pub")).await?;
46    Ok(public_key.trim().to_string())
47}
48
49pub async fn wait_for_ssh(config: &SshConfig, timeout_secs: u64) -> Result<()> {
50    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
51    loop {
52        match ssh_status(config, &["true"]).await {
53            Ok(()) => return Ok(()),
54            Err(err) if Instant::now() < deadline => {
55                let _ = err;
56                sleep(Duration::from_millis(500)).await;
57            }
58            Err(err) => return Err(err),
59        }
60    }
61}
62
63pub async fn open_session(config: &SshConfig, extra_args: &[String]) -> Result<()> {
64    let status = Command::new("ssh")
65        .args(common_ssh_args(config, false))
66        .args(extra_args)
67        .arg(format!("{}@{}", config.user, config.host))
68        .stdin(Stdio::inherit())
69        .stdout(Stdio::inherit())
70        .stderr(Stdio::inherit())
71        .status()
72        .await
73        .context("run ssh")?;
74    if status.success() {
75        Ok(())
76    } else {
77        bail!("ssh exited with status {status}");
78    }
79}
80
81pub async fn exec(config: &SshConfig, command: &[String]) -> Result<()> {
82    let output = exec_capture(config, command).await?;
83    if output.status.success() {
84        print!("{}", output.stdout);
85        eprint!("{}", output.stderr);
86        Ok(())
87    } else {
88        if !output.stdout.is_empty() {
89            print!("{}", output.stdout);
90        }
91        if !output.stderr.is_empty() {
92            eprint!("{}", output.stderr);
93        }
94        bail!("remote command exited with status {}", output.status);
95    }
96}
97
98pub async fn exec_capture(config: &SshConfig, command: &[String]) -> Result<ExecOutput> {
99    let remote_command = render_remote_command(command);
100    let output = Command::new("ssh")
101        .args(common_ssh_args(config, true))
102        .arg(format!("{}@{}", config.user, config.host))
103        .arg(&remote_command)
104        .stdin(Stdio::null())
105        .stdout(Stdio::piped())
106        .stderr(Stdio::piped())
107        .output()
108        .await
109        .context("run ssh")?;
110    Ok(ExecOutput {
111        status: output.status,
112        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
113        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
114    })
115}
116
117pub async fn exec_checked(config: &SshConfig, command: &[String]) -> Result<ExecOutput> {
118    let output = exec_capture(config, command).await?;
119    if output.status.success() {
120        Ok(output)
121    } else {
122        bail!("remote command exited with status {}", output.status);
123    }
124}
125
126async fn ssh_status(config: &SshConfig, remote_command: &[&str]) -> Result<()> {
127    let status = Command::new("ssh")
128        .args(common_ssh_args(config, true))
129        .arg("-o")
130        .arg("ConnectTimeout=2")
131        .arg(format!("{}@{}", config.user, config.host))
132        .args(remote_command)
133        .stdin(Stdio::null())
134        .stdout(Stdio::null())
135        .stderr(Stdio::null())
136        .status()
137        .await
138        .context("run ssh readiness probe")?;
139    if status.success() {
140        Ok(())
141    } else {
142        bail!("ssh not ready yet")
143    }
144}
145
146fn common_ssh_args(config: &SshConfig, batch_mode: bool) -> Vec<String> {
147    let mut args = vec![
148        "-i".to_string(),
149        config.identity_file.display().to_string(),
150        "-p".to_string(),
151        config.port.to_string(),
152        "-o".to_string(),
153        "StrictHostKeyChecking=no".to_string(),
154        "-o".to_string(),
155        "UserKnownHostsFile=/dev/null".to_string(),
156        "-o".to_string(),
157        "IdentitiesOnly=yes".to_string(),
158        "-o".to_string(),
159        "LogLevel=ERROR".to_string(),
160    ];
161    if batch_mode {
162        args.extend(["-o".to_string(), "BatchMode=yes".to_string()]);
163    }
164    args
165}
166
167fn render_remote_command(command: &[String]) -> String {
168    command
169        .iter()
170        .map(|arg| shell_quote(arg))
171        .collect::<Vec<_>>()
172        .join(" ")
173}
174
175fn shell_quote(arg: &str) -> String {
176    format!("'{}'", arg.replace('\'', "'\"'\"'"))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::{render_remote_command, shell_quote};
182
183    #[test]
184    fn shell_quote_escapes_single_quotes() {
185        assert_eq!(shell_quote("it's ready"), r#"'it'"'"'s ready'"#);
186    }
187
188    #[test]
189    fn render_remote_command_preserves_shell_script_argument() {
190        let command = vec![
191            "sh".to_string(),
192            "-lc".to_string(),
193            "sudo apt-get update".to_string(),
194        ];
195        assert_eq!(
196            render_remote_command(&command),
197            "'sh' '-lc' 'sudo apt-get update'"
198        );
199    }
200
201    #[test]
202    fn render_remote_command_preserves_empty_arguments() {
203        let command = vec!["printf".to_string(), "".to_string(), "done".to_string()];
204        assert_eq!(render_remote_command(&command), "'printf' '' 'done'");
205    }
206}