Skip to main content

virtuoso_cli/transport/
ssh.rs

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    /// Dynamically disabled when a ControlMaster failure is detected at runtime.
30    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    /// Detect ControlMaster failure patterns in SSH stderr output.
51    /// These typically appear on Windows/WSL2 when the CM socket path contains
52    /// non-ASCII characters or the named pipe cannot be created.
53    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    /// Base SSH command with login shell args (`sh -l -s`) appended.
115    /// Extracted so the login-shell flag is testable without spawning a process.
116    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        // Must pass "sh -c 'command'" as a single argument to SSH,
195        // otherwise "sh", "-c", "command" are concatenated without quotes,
196        // breaking commands with &&.
197        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        // Must pass "sh -c 'command'" as a single argument to SSH,
234        // otherwise "sh", "-c", "command" are concatenated without quotes,
235        // breaking commands with &&.
236        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            // EDA lab KDC stalls masquerade as banner-exchange timeouts;
331            // disable both auth methods we never use.
332            "-o",
333            "GSSAPIAuthentication=no",
334            "-o",
335            "HostbasedAuthentication=no",
336        ]);
337
338        if self.use_control_master.get() {
339            // ControlMaster: reuse SSH connections to avoid repeated handshakes.
340            // Disabled at runtime if a CM failure is detected (WSL2/Windows paths
341            // with non-ASCII characters, named pipe creation failures, etc.).
342            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    /// Build a minimal SSH command string for manual connectivity verification.
387    /// Useful for error messages when the tunnel cannot be established.
388    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}