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}