1#![allow(dead_code)]
2
3use crate::error::{Result, VirtuosoError};
4use std::cell::Cell;
5use std::collections::HashMap;
6use std::io::Write;
7use std::process::{Command, Stdio};
8use std::time::Instant;
9
10use crate::models::RemoteTaskResult;
11
12fn shell_quote(s: &str) -> String {
13 shlex::try_quote(s)
14 .unwrap_or(std::borrow::Cow::Borrowed(s))
15 .into_owned()
16}
17
18pub struct SSHRunner {
19 pub host: String,
20 pub user: Option<String>,
21 pub jump_host: Option<String>,
22 pub jump_user: Option<String>,
23 pub ssh_port: Option<u16>,
24 pub ssh_key_path: Option<String>,
25 pub ssh_config_path: Option<String>,
26 pub timeout: u64,
27 pub connect_timeout: u64,
28 pub verbose: bool,
29 pub use_control_master: Cell<bool>,
31}
32
33impl SSHRunner {
34 pub fn new(host: &str) -> Self {
35 Self {
36 host: host.into(),
37 user: None,
38 jump_host: None,
39 jump_user: None,
40 ssh_port: None,
41 ssh_key_path: None,
42 ssh_config_path: None,
43 timeout: 30,
44 connect_timeout: 10,
45 verbose: false,
46 use_control_master: Cell::new(true),
47 }
48 }
49
50 pub fn is_cm_failure(stderr: &str) -> bool {
54 stderr.contains("mux_client_request_session")
55 || stderr.contains("could not create named pipe")
56 || stderr.contains("ControlPath")
57 || stderr.contains("Control socket connect")
58 || stderr.contains("multiplexing not supported")
59 || stderr.contains("mux_client_hello_exchange")
60 }
61
62 pub fn with_jump(mut self, jump: &str) -> Self {
63 self.jump_host = Some(jump.into());
64 self
65 }
66
67 pub fn with_user(mut self, user: &str) -> Self {
68 self.user = Some(user.into());
69 self
70 }
71
72 pub fn test_connection(&self, timeout: Option<u64>) -> Result<bool> {
73 let effective_timeout = timeout.unwrap_or(self.connect_timeout);
74 let output = self.run_test_connection(effective_timeout)?;
75
76 if !output.status.success() {
77 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
78 if Self::is_cm_failure(&stderr) && self.use_control_master.get() {
79 tracing::warn!(
80 "ControlMaster failure in test_connection, retrying without CM: {}",
81 stderr.lines().next().unwrap_or("")
82 );
83 self.use_control_master.set(false);
84 let output2 = self.run_test_connection(effective_timeout)?;
85 return Ok(output2.status.success());
86 }
87 }
88 Ok(output.status.success())
89 }
90
91 fn run_test_connection(&self, connect_timeout: u64) -> Result<std::process::Output> {
92 let mut cmd = self.build_ssh_cmd_with_timeout(connect_timeout);
93 cmd.arg("exit").arg("0");
94 cmd.stdin(Stdio::null())
95 .stdout(Stdio::null())
96 .stderr(Stdio::piped())
97 .output()
98 .map_err(|e| VirtuosoError::Ssh(format!("failed to run ssh: {e}")))
99 }
100
101 pub fn run_command(&self, command: &str, timeout: Option<u64>) -> Result<RemoteTaskResult> {
102 let result = self.run_command_inner(command, timeout)?;
103 if !result.success && Self::is_cm_failure(&result.stderr) && self.use_control_master.get() {
104 tracing::warn!(
105 "ControlMaster failure detected, retrying without CM: {}",
106 result.stderr.lines().next().unwrap_or("")
107 );
108 self.use_control_master.set(false);
109 return self.run_command_inner(command, timeout);
110 }
111 Ok(result)
112 }
113
114 pub(crate) fn build_run_cmd(&self) -> Command {
117 let mut cmd = self.build_ssh_cmd();
118 cmd.arg("sh").arg("-l").arg("-s");
119 cmd
120 }
121
122 fn run_command_inner(&self, command: &str, timeout: Option<u64>) -> Result<RemoteTaskResult> {
123 let _timeout = timeout.unwrap_or(self.timeout);
124 let start = Instant::now();
125
126 let mut cmd = self.build_run_cmd();
127
128 let output = cmd
129 .stdin(Stdio::piped())
130 .stdout(Stdio::piped())
131 .stderr(Stdio::piped())
132 .spawn()
133 .map_err(|e| VirtuosoError::Ssh(format!("failed to spawn ssh: {e}")))?;
134
135 if let Some(mut stdin) = output.stdin.as_ref() {
136 stdin
137 .write_all(command.as_bytes())
138 .map_err(|e| VirtuosoError::Ssh(format!("failed to write command: {e}")))?;
139 }
140
141 let output = output
142 .wait_with_output()
143 .map_err(|e| VirtuosoError::Ssh(format!("ssh failed: {e}")))?;
144
145 let elapsed = start.elapsed().as_secs_f64();
146 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
147 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
148 let error = if output.status.success() {
149 None
150 } else {
151 Some(self.summarize_error(&stderr))
152 };
153
154 let mut timings = HashMap::new();
155 timings.insert("total".into(), elapsed);
156
157 Ok(RemoteTaskResult {
158 success: output.status.success(),
159 returncode: output.status.code().unwrap_or(-1),
160 stdout,
161 stderr,
162 remote_dir: None,
163 error,
164 timings,
165 })
166 }
167
168 pub fn upload(&self, local: &str, remote: &str) -> Result<()> {
169 let _target = self.remote_target();
170
171 let status = Command::new("tar")
172 .arg("cf")
173 .arg("-")
174 .arg("-C")
175 .arg(
176 std::path::Path::new(local)
177 .parent()
178 .unwrap_or_else(|| std::path::Path::new(".")),
179 )
180 .arg(std::path::Path::new(local).file_name().unwrap_or_default())
181 .stdout(Stdio::piped())
182 .spawn()
183 .map_err(|e| VirtuosoError::Ssh(format!("tar failed: {e}")))?;
184
185 let tar_stdout = status.stdout.unwrap();
186
187 let remote_dir = std::path::Path::new(remote)
188 .parent()
189 .unwrap_or_else(|| std::path::Path::new("."))
190 .to_string_lossy();
191
192 let mut ssh = self.build_ssh_cmd();
193 let quoted_dir = shell_quote(&remote_dir);
194 let inner_cmd = format!("mkdir -p {quoted_dir} && cd {quoted_dir} && tar xf -");
198 ssh.arg(format!("sh -c {}", shell_quote(&inner_cmd)));
199 ssh.stdin(tar_stdout);
200
201 let output = ssh
202 .stdout(Stdio::null())
203 .stderr(Stdio::piped())
204 .output()
205 .map_err(|e| VirtuosoError::Ssh(format!("ssh upload failed: {e}")))?;
206
207 if !output.status.success() {
208 let stderr = String::from_utf8_lossy(&output.stderr);
209 return Err(VirtuosoError::Ssh(format!("upload failed: {stderr}")));
210 }
211
212 Ok(())
213 }
214
215 pub fn upload_text(&self, text: &str, remote: &str) -> Result<()> {
216 let remote_dir = std::path::Path::new(remote)
217 .parent()
218 .unwrap_or_else(|| std::path::Path::new("."))
219 .to_string_lossy();
220
221 let quoted_dir = shell_quote(&remote_dir);
222 let mkdir_cmd = format!("mkdir -p {quoted_dir}");
223 let mkdir = self.run_command(&mkdir_cmd, None)?;
224 if !mkdir.success {
225 return Err(VirtuosoError::Ssh(format!(
226 "failed to create remote dir: {}",
227 mkdir.stderr
228 )));
229 }
230
231 let mut cmd = self.build_ssh_cmd();
232 let quoted_remote = shell_quote(remote);
233 cmd.arg(format!(
237 "sh -c {}",
238 shell_quote(&format!("cat > {quoted_remote}"))
239 ));
240
241 let output = cmd
242 .stdin(Stdio::piped())
243 .stdout(Stdio::null())
244 .stderr(Stdio::piped())
245 .spawn()
246 .map_err(|e| VirtuosoError::Ssh(format!("ssh failed: {e}")))?;
247
248 if let Some(mut stdin) = output.stdin.as_ref() {
249 stdin
250 .write_all(text.as_bytes())
251 .map_err(|e| VirtuosoError::Ssh(format!("write failed: {e}")))?;
252 }
253
254 let output = output
255 .wait_with_output()
256 .map_err(|e| VirtuosoError::Ssh(format!("upload failed: {e}")))?;
257
258 if !output.status.success() {
259 let stderr = String::from_utf8_lossy(&output.stderr);
260 return Err(VirtuosoError::Ssh(format!("upload failed: {stderr}")));
261 }
262
263 Ok(())
264 }
265
266 pub fn download(&self, remote: &str, local: &str) -> Result<()> {
267 let _target = self.remote_target();
268
269 let local_path = std::path::Path::new(local);
270 if let Some(parent) = local_path.parent() {
271 std::fs::create_dir_all(parent)
272 .map_err(|e| VirtuosoError::Ssh(format!("failed to create local dir: {e}")))?;
273 }
274
275 let mut cmd = self.build_ssh_cmd();
276 cmd.arg("cat").arg(remote);
277
278 let output = cmd
279 .stdout(Stdio::piped())
280 .stderr(Stdio::piped())
281 .output()
282 .map_err(|e| VirtuosoError::Ssh(format!("ssh download failed: {e}")))?;
283
284 if !output.status.success() {
285 let stderr = String::from_utf8_lossy(&output.stderr);
286 return Err(VirtuosoError::Ssh(format!("download failed: {stderr}")));
287 }
288
289 std::fs::write(local, output.stdout)
290 .map_err(|e| VirtuosoError::Ssh(format!("failed to write local file: {e}")))?;
291
292 Ok(())
293 }
294
295 pub fn detect_python(&self) -> Result<Option<String>> {
296 for py in &["python3", "python", "python2.7"] {
297 let result = self.run_command(&format!("which {py} 2>/dev/null"), None)?;
298 if result.success && !result.stdout.trim().is_empty() {
299 return Ok(Some(py.to_string()));
300 }
301 }
302 Ok(None)
303 }
304
305 pub fn detect_arch(&self) -> Result<String> {
306 let result = self.run_command("uname -m", None)?;
307 if result.success {
308 Ok(result.stdout.trim().to_string())
309 } else {
310 Err(VirtuosoError::Ssh(format!(
311 "failed to detect arch: {}",
312 result.stderr
313 )))
314 }
315 }
316
317 pub(crate) fn build_ssh_cmd(&self) -> Command {
318 self.build_ssh_cmd_with_timeout(self.connect_timeout)
319 }
320
321 fn build_ssh_cmd_with_timeout(&self, connect_timeout: u64) -> Command {
322 let mut cmd = Command::new("ssh");
323 cmd.args([
324 "-o",
325 "BatchMode=yes",
326 "-o",
327 "StrictHostKeyChecking=accept-new",
328 "-o",
329 &format!("ConnectTimeout={connect_timeout}"),
330 "-o",
333 "GSSAPIAuthentication=no",
334 "-o",
335 "HostbasedAuthentication=no",
336 ]);
337
338 if self.use_control_master.get() {
339 let control_dir = dirs::cache_dir()
343 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
344 .join("virtuoso_bridge")
345 .join("ssh");
346 let _ = std::fs::create_dir_all(&control_dir);
347 let control_path = control_dir.join("%h-%p-%r");
348 cmd.args([
349 "-o",
350 "ControlMaster=auto",
351 "-o",
352 &format!("ControlPath={}", control_path.display()),
353 "-o",
354 "ControlPersist=600",
355 ]);
356 }
357
358 if let Some(port) = self.ssh_port {
359 cmd.arg("-p").arg(port.to_string());
360 }
361 if let Some(ref key) = self.ssh_key_path {
362 cmd.arg("-i").arg(key);
363 }
364 if let Some(ref config) = self.ssh_config_path {
365 cmd.arg("-F").arg(config);
366 }
367 if let Some(ref jump) = self.jump_host {
368 let jump_target = match &self.jump_user {
369 Some(u) => format!("{u}@{jump}"),
370 None => jump.clone(),
371 };
372 cmd.arg("-J").arg(jump_target);
373 }
374
375 cmd.arg(self.remote_target());
376 cmd
377 }
378
379 pub fn remote_target(&self) -> String {
380 match &self.user {
381 Some(u) => format!("{u}@{}", self.host),
382 None => self.host.clone(),
383 }
384 }
385
386 pub fn verify_cmd_hint(&self) -> String {
389 let mut parts = vec!["ssh".to_string()];
390 if let Some(ref jump) = self.jump_host {
391 let jump_target = match &self.jump_user {
392 Some(u) => format!("{u}@{jump}"),
393 None => jump.clone(),
394 };
395 parts.push(format!("-J {jump_target}"));
396 }
397 if let Some(port) = self.ssh_port {
398 parts.push(format!("-p {port}"));
399 }
400 if let Some(ref key) = self.ssh_key_path {
401 parts.push(format!("-i {key}"));
402 }
403 parts.push(self.remote_target());
404 parts.join(" ")
405 }
406
407 pub(crate) fn summarize_error(&self, stderr: &str) -> String {
408 let lower = stderr.to_lowercase();
409 if lower.contains("connection refused") {
410 "connection refused - check if SSH is running".into()
411 } else if lower.contains("authentication") || lower.contains("permission denied") {
412 "authentication failed - check SSH keys".into()
413 } else if lower.contains("timeout") || lower.contains("timed out") {
414 "connection timed out - check network".into()
415 } else if lower.contains("could not resolve") {
416 "hostname resolution failed - check DNS".into()
417 } else {
418 stderr.lines().take(3).collect::<Vec<_>>().join("; ")
419 }
420 }
421}