1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use log::{debug, error, info, warn};
6
7pub struct ConnectResult {
9 pub status: std::process::ExitStatus,
10 pub stderr_output: String,
11}
12
13#[cfg(unix)]
15pub fn is_in_tmux() -> bool {
16 std::env::var("TMUX").is_ok()
17}
18
19#[cfg(not(unix))]
21pub fn is_in_tmux() -> bool {
22 false
23}
24
25pub fn connect_tmux_window(alias: &str, config_path: &Path, has_active_tunnel: bool) -> Result<()> {
37 info!("SSH connection via tmux: {alias}");
38
39 let config_str = config_path
40 .to_str()
41 .context("SSH config path is not valid UTF-8")?;
42
43 let mut args = vec!["new-window", "-n", alias, "--", "ssh", "-F", config_str];
44
45 if has_active_tunnel {
46 args.extend(["-o", "ClearAllForwardings=yes"]);
47 }
48
49 args.extend(["--", alias]);
50
51 debug!("tmux args: {:?}", args);
52
53 let status = Command::new("tmux")
54 .args(&args)
55 .status()
56 .with_context(|| format!("Failed to launch tmux new-window for '{alias}'"))?;
57
58 if status.success() {
59 info!("tmux window created: {alias}");
60 Ok(())
61 } else {
62 let code = status.code().unwrap_or(-1);
63 error!("[external] tmux new-window failed for {alias} (exit {code})");
64 anyhow::bail!("tmux new-window exited with code {code}")
65 }
66}
67
68#[cfg(unix)]
71struct SignalMaskGuard {
72 old: libc::sigset_t,
73}
74
75#[cfg(unix)]
76impl SignalMaskGuard {
77 fn block_interactive() -> Self {
79 unsafe {
84 let mut old: libc::sigset_t = std::mem::zeroed();
85 let mut mask: libc::sigset_t = std::mem::zeroed();
86 libc::sigemptyset(&mut mask);
87 libc::sigaddset(&mut mask, libc::SIGINT);
88 libc::sigaddset(&mut mask, libc::SIGTSTP);
89 libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old);
90 Self { old }
91 }
92 }
93}
94
95#[cfg(unix)]
96impl Drop for SignalMaskGuard {
97 fn drop(&mut self) {
98 unsafe {
104 let mut pending: libc::sigset_t = std::mem::zeroed();
108 libc::sigpending(&mut pending);
109 let has_sigint = libc::sigismember(&pending, libc::SIGINT) == 1;
110 let has_sigtstp = libc::sigismember(&pending, libc::SIGTSTP) == 1;
111 if has_sigint {
113 libc::signal(libc::SIGINT, libc::SIG_IGN);
114 }
115 if has_sigtstp {
116 libc::signal(libc::SIGTSTP, libc::SIG_IGN);
117 }
118 libc::sigprocmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut());
119 if has_sigint {
121 libc::signal(libc::SIGINT, libc::SIG_DFL);
122 }
123 if has_sigtstp {
124 libc::signal(libc::SIGTSTP, libc::SIG_DFL);
125 }
126 }
127 }
128}
129
130fn spawn_ssh_and_wait(mut cmd: Command, alias: &str, log_label: &str) -> Result<ConnectResult> {
140 cmd.stdin(std::process::Stdio::inherit())
141 .stdout(std::process::Stdio::inherit())
142 .stderr(std::process::Stdio::piped());
143
144 #[cfg(unix)]
148 unsafe {
149 use std::os::unix::process::CommandExt;
150 cmd.pre_exec(|| {
151 let mut mask: libc::sigset_t = std::mem::zeroed();
152 libc::sigemptyset(&mut mask);
153 libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut());
154 Ok(())
155 });
156 }
157
158 let mut child = cmd
159 .spawn()
160 .with_context(|| format!("Failed to launch ssh {} for '{}'", log_label, alias))?;
161
162 #[cfg(unix)]
166 let _signal_guard = SignalMaskGuard::block_interactive();
167
168 let stderr_pipe = child.stderr.take().expect("stderr was piped");
169 let stderr_thread = std::thread::spawn(move || {
170 use std::io::{Read, Write};
171 let mut captured = Vec::new();
172 let mut buf = [0u8; 4096];
173 let mut reader = stderr_pipe;
174 let mut stderr_out = std::io::stderr();
175 loop {
176 match reader.read(&mut buf) {
177 Ok(0) => break,
178 Ok(n) => {
179 let _ = stderr_out.write_all(&buf[..n]);
180 let _ = stderr_out.flush();
181 captured.extend_from_slice(&buf[..n]);
182 }
183 Err(_) => break,
184 }
185 }
186 String::from_utf8_lossy(&captured).to_string()
187 });
188
189 let status = child
190 .wait()
191 .with_context(|| format!("Failed to wait for ssh {} for '{}'", log_label, alias))?;
192 let stderr_output = stderr_thread.join().unwrap_or_else(|_| {
193 warn!("[purple] Stderr capture thread panicked for {alias}");
194 String::new()
195 });
196
197 let code = status.code().unwrap_or(-1);
198 if code == 0 {
199 info!("SSH {} ended: {alias} (exit 0)", log_label);
200 } else {
201 error!("[external] SSH {} failed: {alias} (exit {code})", log_label);
202 if !stderr_output.is_empty() {
203 let stderr = stderr_output.trim();
204 let lower = stderr.to_lowercase();
205 if lower.contains("are too open") || lower.contains("bad permissions") {
206 warn!("[config] SSH key permission issue: {stderr}");
207 } else {
208 debug!("[external] SSH stderr: {stderr}");
209 }
210 }
211 }
212
213 Ok(ConnectResult {
214 status,
215 stderr_output,
216 })
217}
218
219pub fn connect(
226 alias: &str,
227 config_path: &Path,
228 askpass: Option<&str>,
229 bw_session: Option<&str>,
230 has_active_tunnel: bool,
231) -> Result<ConnectResult> {
232 info!("SSH connection started: {alias}");
233 debug!("SSH command: ssh -F {} -- {alias}", config_path.display());
234
235 let mut cmd = Command::new("ssh");
236 cmd.arg("-F").arg(config_path);
237
238 if has_active_tunnel {
241 cmd.arg("-o").arg("ClearAllForwardings=yes");
242 }
243
244 cmd.arg("--").arg(alias);
245
246 if askpass.is_some() {
247 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
248 }
249
250 if let Some(token) = bw_session {
251 cmd.env("BW_SESSION", token);
252 }
253
254 spawn_ssh_and_wait(cmd, alias, "connection")
255}
256
257pub fn connect_with_remote_command(
270 alias: &str,
271 config_path: &Path,
272 askpass: Option<&str>,
273 bw_session: Option<&str>,
274 has_active_tunnel: bool,
275 remote_command: &str,
276) -> Result<ConnectResult> {
277 info!("SSH exec started: {alias}");
278 debug!(
279 "SSH command: ssh -F {} -t -- {alias} {}",
280 config_path.display(),
281 remote_command
282 );
283
284 crate::runtime::helpers::ensure_vault_cert_for_alias(alias, config_path);
288
289 let mut cmd = Command::new("ssh");
290 cmd.arg("-F").arg(config_path).arg("-t");
291
292 if has_active_tunnel {
293 cmd.arg("-o").arg("ClearAllForwardings=yes");
294 }
295
296 cmd.arg("--").arg(alias).arg(remote_command);
297
298 if askpass.is_some() {
299 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
300 }
301
302 if let Some(token) = bw_session {
303 cmd.env("BW_SESSION", token);
304 }
305
306 spawn_ssh_and_wait(cmd, alias, "exec")
307}
308
309pub fn connect_tmux_window_with_remote_command(
314 alias: &str,
315 config_path: &Path,
316 has_active_tunnel: bool,
317 remote_command: &str,
318 window_label: &str,
319) -> Result<()> {
320 info!("SSH exec via tmux: {alias}");
321
322 crate::runtime::helpers::ensure_vault_cert_for_alias(alias, config_path);
326
327 let config_str = config_path
328 .to_str()
329 .context("SSH config path is not valid UTF-8")?;
330
331 let mut args = vec![
332 "new-window",
333 "-n",
334 window_label,
335 "--",
336 "ssh",
337 "-F",
338 config_str,
339 "-t",
340 ];
341
342 if has_active_tunnel {
343 args.extend(["-o", "ClearAllForwardings=yes"]);
344 }
345
346 args.extend(["--", alias, remote_command]);
347
348 debug!("tmux exec args: {:?}", args);
349
350 let status = Command::new("tmux")
351 .args(&args)
352 .status()
353 .with_context(|| format!("Failed to launch tmux exec window for '{alias}'"))?;
354
355 if status.success() {
356 info!("tmux exec window created: {alias}");
357 Ok(())
358 } else {
359 let code = status.code().unwrap_or(-1);
360 error!("[external] tmux exec window failed for {alias} (exit {code})");
361 anyhow::bail!("tmux new-window exited with code {code}")
362 }
363}
364
365pub fn stderr_summary(stderr: &str) -> Option<String> {
369 let summary: String = stderr
370 .lines()
371 .map(str::trim)
372 .filter(|l| !l.is_empty() && !l.starts_with('@'))
373 .collect::<Vec<_>>()
374 .join(" | ");
375 if summary.is_empty() {
376 return None;
377 }
378 if summary.len() > 200 {
379 let truncated: String = summary.chars().take(197).collect();
380 Some(format!("{truncated}..."))
381 } else {
382 Some(summary)
383 }
384}
385
386pub fn parse_host_key_error(stderr: &str) -> Option<(String, String)> {
396 let has_english_error = stderr.contains("Host key verification failed.");
398 let has_banner = stderr.contains("@@@@@@@@@@@@@@@");
400
401 if !has_english_error && !has_banner {
402 return None;
403 }
404
405 let hostname = stderr
407 .lines()
408 .find(|l| l.contains("Host key for") && l.contains("has changed"))
409 .and_then(|l| {
410 let start = l.find("Host key for ")? + "Host key for ".len();
411 let rest = &l[start..];
412 let end = rest.find(" has changed")?;
413 Some(rest[..end].to_string())
414 });
415
416 let known_hosts_path = stderr
418 .lines()
419 .find(|l| l.starts_with("Offending") && l.contains(" key in "))
420 .and_then(|l| {
421 let start = l.find(" key in ")? + " key in ".len();
422 let rest = &l[start..];
423 let end = rest.rfind(':')?;
424 Some(rest[..end].to_string())
425 });
426
427 let known_hosts_path = known_hosts_path?;
429
430 let hostname = hostname.unwrap_or_else(|| "the remote host".to_string());
435
436 Some((hostname, known_hosts_path))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn connect_fails_with_nonexistent_config() {
445 let _guard = crate::vault_ssh::tests::ENV_LOCK
451 .lock()
452 .unwrap_or_else(|p| p.into_inner());
453 let result = connect(
454 "nonexistent-host",
455 Path::new("/tmp/__purple_test_nonexistent_config__"),
456 None,
457 None,
458 false,
459 );
460 assert!(result.is_ok()); let r = result.unwrap();
463 assert!(!r.status.success());
464 }
465
466 #[test]
467 fn connect_with_tunnel_flag_does_not_panic() {
468 let _guard = crate::vault_ssh::tests::ENV_LOCK
471 .lock()
472 .unwrap_or_else(|p| p.into_inner());
473 let result = connect(
474 "nonexistent-host",
475 Path::new("/tmp/__purple_test_nonexistent_config__"),
476 None,
477 None,
478 true,
479 );
480 assert!(result.is_ok());
481 assert!(!result.unwrap().status.success());
482 }
483
484 #[test]
485 fn connect_captures_stderr() {
486 let _guard = crate::vault_ssh::tests::ENV_LOCK
489 .lock()
490 .unwrap_or_else(|p| p.into_inner());
491 let result = connect(
492 "nonexistent-host",
493 Path::new("/tmp/__purple_test_nonexistent_config__"),
494 None,
495 None,
496 false,
497 );
498 assert!(result.is_ok());
499 let r = result.unwrap();
502 assert!(
503 !r.stderr_output.is_empty() || !r.status.success(),
504 "SSH should produce stderr or fail"
505 );
506 }
507
508 #[test]
511 fn parse_host_key_error_detects_changed_key() {
512 let stderr = "\
513@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
514@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
515@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
516IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
517Someone could be eavesdropping on you right now (man-in-the-middle attack)!
518It is also possible that a host key has just been changed.
519The fingerprint for the ED25519 key sent by the remote host is
520SHA256:ohwPXZbfBMvYWXnKefVYWVAcQsXKLMqaRKbXxRUVXqc.
521Please contact your system administrator.
522Add correct host key in /Users/user/.ssh/known_hosts to get rid of this message.
523Offending ECDSA key in /Users/user/.ssh/known_hosts:55
524Host key for example.com has changed and you have requested strict checking.
525Host key verification failed.
526";
527 let result = parse_host_key_error(stderr);
528 assert!(result.is_some());
529 let (hostname, path) = result.unwrap();
530 assert_eq!(hostname, "example.com");
531 assert_eq!(path, "/Users/user/.ssh/known_hosts");
532 }
533
534 #[test]
535 fn parse_host_key_error_returns_none_for_other_errors() {
536 let stderr = "ssh: connect to host example.com port 22: Connection refused\n";
537 assert!(parse_host_key_error(stderr).is_none());
538 }
539
540 #[test]
541 fn parse_host_key_error_returns_none_for_empty() {
542 assert!(parse_host_key_error("").is_none());
543 }
544
545 #[test]
546 fn parse_host_key_error_handles_ip_address() {
547 let stderr = "\
548Offending ECDSA key in /home/user/.ssh/known_hosts:12
549Host key for 10.0.0.1 has changed and you have requested strict checking.
550Host key verification failed.
551";
552 let result = parse_host_key_error(stderr);
553 assert!(result.is_some());
554 let (hostname, path) = result.unwrap();
555 assert_eq!(hostname, "10.0.0.1");
556 assert_eq!(path, "/home/user/.ssh/known_hosts");
557 }
558
559 #[test]
560 fn parse_host_key_error_handles_custom_known_hosts_path() {
561 let stderr = "\
562Offending RSA key in /etc/ssh/known_hosts:3
563Host key for server.local has changed and you have requested strict checking.
564Host key verification failed.
565";
566 let result = parse_host_key_error(stderr);
567 assert!(result.is_some());
568 let (hostname, path) = result.unwrap();
569 assert_eq!(hostname, "server.local");
570 assert_eq!(path, "/etc/ssh/known_hosts");
571 }
572
573 #[test]
574 fn parse_host_key_error_handles_ipv6() {
575 let stderr = "\
576Offending ED25519 key in /Users/user/.ssh/known_hosts:7
577Host key for ::1 has changed and you have requested strict checking.
578Host key verification failed.
579";
580 let result = parse_host_key_error(stderr);
581 assert!(result.is_some());
582 let (hostname, _) = result.unwrap();
583 assert_eq!(hostname, "::1");
584 }
585
586 #[test]
587 fn connect_tmux_window_fails_gracefully_outside_tmux_session() {
588 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
593 if std::env::var("TMUX").is_ok() {
594 return;
595 }
596 let result = connect_tmux_window(
597 "test-host",
598 Path::new("/tmp/__purple_test_nonexistent_config__"),
599 false,
600 );
601 assert!(result.is_err());
602 let err = result.unwrap_err().to_string();
603 assert!(
604 err.contains("tmux") || err.contains("No such file"),
605 "unexpected error: {err}"
606 );
607 }
608
609 #[test]
610 fn connect_tmux_window_with_tunnel_does_not_panic() {
611 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
615 if std::env::var("TMUX").is_ok() {
616 return;
617 }
618 let result = connect_tmux_window(
619 "tunnel-host",
620 Path::new("/tmp/__purple_test_nonexistent_config__"),
621 true,
622 );
623 assert!(result.is_err());
624 }
625
626 static TMUX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
628
629 #[test]
630 fn is_in_tmux_returns_true_when_set() {
631 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
632 let prev = std::env::var("TMUX").ok();
633 unsafe { std::env::set_var("TMUX", "/tmp/tmux-1000/default,12345,0") };
635 let result = is_in_tmux();
636 match prev {
638 Some(v) => unsafe { std::env::set_var("TMUX", v) },
639 None => unsafe { std::env::remove_var("TMUX") },
640 }
641 assert!(result);
642 }
643
644 #[test]
645 fn is_in_tmux_returns_false_when_unset() {
646 let _guard = TMUX_LOCK.lock().unwrap_or_else(|p| p.into_inner());
647 let prev = std::env::var("TMUX").ok();
648 unsafe { std::env::remove_var("TMUX") };
650 let result = is_in_tmux();
651 if let Some(v) = prev {
653 unsafe { std::env::set_var("TMUX", v) };
654 }
655 assert!(!result);
656 }
657
658 #[test]
661 fn stderr_summary_joins_all_lines() {
662 let stderr = "channel 0: open failed: administratively prohibited: open failed\n\
663 stdio forwarding failed\n\
664 Connection closed by UNKNOWN port 65535\n";
665 let result = stderr_summary(stderr);
666 assert_eq!(
667 result.as_deref(),
668 Some(
669 "channel 0: open failed: administratively prohibited: open failed | stdio forwarding failed | Connection closed by UNKNOWN port 65535"
670 )
671 );
672 }
673
674 #[test]
675 fn stderr_summary_skips_banner_lines() {
676 let stderr = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
677 @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\n\
678 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
679 IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\n";
680 let result = stderr_summary(stderr);
681 assert_eq!(
682 result.as_deref(),
683 Some("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!")
684 );
685 }
686
687 #[test]
688 fn stderr_summary_returns_none_for_empty() {
689 assert!(stderr_summary("").is_none());
690 assert!(stderr_summary(" \n \n").is_none());
691 assert!(stderr_summary("@@@@@\n@@@@@\n").is_none());
692 }
693
694 #[test]
695 fn stderr_summary_truncates_long_output() {
696 let long = "x".repeat(250);
697 let result = stderr_summary(&long).unwrap();
698 assert_eq!(result.len(), 200);
699 assert!(result.ends_with("..."));
700 }
701
702 #[test]
703 fn stderr_summary_truncates_multibyte_safely() {
704 let long = "日".repeat(100);
706 let result = stderr_summary(&long).unwrap();
707 assert!(result.ends_with("..."));
708 assert!(result.len() <= 600); }
711
712 #[test]
713 fn stderr_summary_simple_errors() {
714 assert_eq!(
715 stderr_summary("Connection refused\n").as_deref(),
716 Some("Connection refused")
717 );
718 assert_eq!(
719 stderr_summary("Permission denied (publickey).\n").as_deref(),
720 Some("Permission denied (publickey).")
721 );
722 }
723}