1use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10
11use russh::Channel;
12use russh::client::{self, Handle};
13use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
14use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, Semaphore};
15use tokio::time::{sleep, timeout};
16use tracing::{debug, error, info, warn};
17
18use super::config::{HostKeyCheckMode, SshConfig};
19use super::handler::{
20 KeyCheckOutcome, SshHandler, default_known_hosts_path, remove_known_hosts_entry,
21};
22use crate::config::CONNECTION_TIMEOUT_SECS;
23use crate::error::{Result, SshMcpError};
24use russh::ChannelMsg;
25
26pub const CHANNEL_SEMAPHORE_CAPACITY: usize = 8;
28const AUTH_TIMEOUT_SECS: u64 = 20;
29const CONNECT_WAIT_TIMEOUT_SECS: u64 = CONNECTION_TIMEOUT_SECS + AUTH_TIMEOUT_SECS;
30const MAX_RECONNECT_BACKOFF_MS: u64 = 30_000;
31const MIN_HEALTH_PROBE_TTL_MS: u64 = 250;
32const MAX_HEALTH_PROBE_TTL_MS: u64 = 5_000;
33
34struct ConnectAttemptGuard<'a> {
35 is_connecting: &'a AtomicBool,
36 connect_notify: &'a Notify,
37}
38
39impl Drop for ConnectAttemptGuard<'_> {
40 fn drop(&mut self) {
41 self.is_connecting.store(false, Ordering::SeqCst);
42 self.connect_notify.notify_waiters();
43 }
44}
45
46pub struct SshConnectionManager {
54 pub(crate) config: SshConfig,
57
58 session: Arc<Mutex<Option<Handle<SshHandler>>>>,
60
61 is_connecting: AtomicBool,
63
64 connect_notify: Arc<Notify>,
66
67 pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
70
71 pub(crate) is_elevated: AtomicBool,
74
75 has_timeout_cmd: AtomicBool,
77
78 pub(crate) channel_semaphore: Arc<Semaphore>,
81
82 last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
84
85 health_probe_lock: Arc<Mutex<()>>,
87}
88
89impl SshConnectionManager {
90 pub async fn new(config: SshConfig) -> Self {
95 Self {
96 config,
97 session: Arc::new(Mutex::new(None)),
98 is_connecting: AtomicBool::new(false),
99 connect_notify: Arc::new(Notify::new()),
100 su_channel: Arc::new(Mutex::new(None)),
101 is_elevated: AtomicBool::new(false),
102 has_timeout_cmd: AtomicBool::new(false),
103 channel_semaphore: Arc::new(Semaphore::new(CHANNEL_SEMAPHORE_CAPACITY)),
104 last_health_probe_ok_at: Arc::new(Mutex::new(None)),
105 health_probe_lock: Arc::new(Mutex::new(())),
106 }
107 }
108
109 pub(crate) async fn acquire_command_slot_raw(
110 &self,
111 ) -> std::result::Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
112 self.channel_semaphore.clone().acquire_owned().await
113 }
114
115 pub(crate) async fn acquire_command_slot(&self) -> Result<OwnedSemaphorePermit> {
116 self.acquire_command_slot_raw()
117 .await
118 .map_err(|e| SshMcpError::connection(format!("Failed to acquire command slot: {e}")))
119 }
120
121 pub async fn connect(&self) -> Result<()> {
126 if self.is_connected().await {
128 debug!("Already connected to SSH server");
129 return Ok(());
130 }
131
132 let notified = self.connect_notify.notified();
134 tokio::pin!(notified);
135 notified.as_mut().enable();
136
137 if self
139 .is_connecting
140 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
141 .is_err()
142 {
143 debug!("Another connection attempt in progress, waiting...");
144 let wait_result =
146 timeout(Duration::from_secs(CONNECT_WAIT_TIMEOUT_SECS), notified).await;
147 if wait_result.is_err() {
148 warn!(
149 "Timed out waiting for in-flight connection attempt after {}s",
150 CONNECT_WAIT_TIMEOUT_SECS
151 );
152 return Err(SshMcpError::connection(format!(
153 "Timed out waiting for in-flight connection attempt after {}s",
154 CONNECT_WAIT_TIMEOUT_SECS
155 )));
156 }
157 return if self.is_connected().await {
158 Ok(())
159 } else {
160 Err(SshMcpError::connection("Connection failed by another task"))
161 };
162 }
163
164 let _attempt_guard = ConnectAttemptGuard {
166 is_connecting: &self.is_connecting,
167 connect_notify: self.connect_notify.as_ref(),
168 };
169
170 self.do_connect().await
171 }
172
173 async fn do_connect(&self) -> Result<()> {
179 info!(
180 "Connecting to SSH server {}:{}...",
181 self.config.host, self.config.port
182 );
183
184 let connection_timeout = Duration::from_secs(CONNECTION_TIMEOUT_SECS);
185
186 let ssh_config = Arc::new(client::Config {
187 keepalive_interval: Some(Duration::from_secs(self.config.keepalive_interval)),
188 keepalive_max: self.config.keepalive_max as usize,
189 ..Default::default()
190 });
191
192 let addr = format!("{}:{}", self.config.host, self.config.port);
193
194 let key_outcome = Arc::new(std::sync::Mutex::new(None::<KeyCheckOutcome>));
196 let handler = SshHandler::new(
197 self.config.host.clone(),
198 self.config.port,
199 self.config.host_key_checking,
200 self.config.known_hosts.clone(),
201 )
202 .with_key_check_outcome(key_outcome.clone());
203
204 match self
205 .attempt_connect(&ssh_config, &addr, handler, connection_timeout)
206 .await
207 {
208 Ok(session) => self.finish_connect(session).await,
209 Err(first_err) => {
210 let outcome = key_outcome.lock().unwrap().take();
213 if matches!(outcome, Some(KeyCheckOutcome::KeyChanged))
214 && self.config.host_key_checking == HostKeyCheckMode::AcceptNew
215 {
216 let Some(path) = self.resolve_known_hosts_path() else {
217 error!(
218 host = %self.config.host,
219 port = self.config.port,
220 "Host key changed but cannot resolve known_hosts path for recovery"
221 );
222 return Err(first_err);
223 };
224
225 warn!(
226 host = %self.config.host,
227 port = self.config.port,
228 path = %path.display(),
229 "Host key changed in accept-new mode; \
230 removing stale known_hosts entry and retrying once"
231 );
232 remove_known_hosts_entry(&self.config.host, self.config.port, &path).map_err(
233 |e| {
234 SshMcpError::connection(format!(
235 "Failed to remove stale known_hosts entry: {e}"
236 ))
237 },
238 )?;
239
240 let retry_handler = SshHandler::new(
242 self.config.host.clone(),
243 self.config.port,
244 self.config.host_key_checking,
245 self.config.known_hosts.clone(),
246 );
247 match self
248 .attempt_connect(&ssh_config, &addr, retry_handler, connection_timeout)
249 .await
250 {
251 Ok(session) => {
252 info!(
253 host = %self.config.host,
254 port = self.config.port,
255 "SSH reconnection succeeded after host key rotation"
256 );
257 self.finish_connect(session).await
258 }
259 Err(retry_err) => {
260 error!(
261 error = ?retry_err,
262 "SSH connection failed on retry after key rotation"
263 );
264 Err(retry_err)
265 }
266 }
267 } else {
268 error!(error = ?first_err, "SSH connection failed");
269 Err(first_err)
270 }
271 }
272 }
273 }
274
275 async fn attempt_connect(
277 &self,
278 ssh_config: &Arc<client::Config>,
279 addr: &str,
280 handler: SshHandler,
281 connection_timeout: Duration,
282 ) -> Result<Handle<SshHandler>> {
283 timeout(
284 connection_timeout,
285 client::connect(ssh_config.clone(), addr, handler),
286 )
287 .await
288 .map_err(|_| {
289 error!("SSH connection timeout after {}s", CONNECTION_TIMEOUT_SECS);
290 SshMcpError::connection(format!(
291 "Connection timeout after {}s",
292 CONNECTION_TIMEOUT_SECS
293 ))
294 })?
295 .map_err(|e| SshMcpError::connection(e.to_string()))
296 }
297
298 async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
300 self.authenticate(&mut session).await?;
302
303 {
305 let mut session_guard = self.session.lock().await;
306 *session_guard = Some(session);
307 }
308 {
309 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
310 *probe_guard = None;
311 }
312
313 info!(
314 "Successfully connected to {}@{}:{}",
315 self.config.username, self.config.host, self.config.port
316 );
317
318 if self.config.su_password.is_some() {
320 debug!("su_password configured, attempting elevation...");
321 if let Err(e) = self.ensure_elevated().await {
322 warn!(error = ?e, "Failed to elevate to root. Commands will run as normal user.");
323 }
324 }
325
326 Ok(())
327 }
328
329 fn resolve_known_hosts_path(&self) -> Option<PathBuf> {
331 self.config
332 .known_hosts
333 .clone()
334 .or_else(default_known_hosts_path)
335 }
336
337 async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
339 if let Some(ref password) = self.config.password {
341 debug!(
342 "Attempting password authentication for user '{}'",
343 self.config.username
344 );
345 let auth_result = timeout(
346 Duration::from_secs(AUTH_TIMEOUT_SECS),
347 session.authenticate_password(&self.config.username, password),
348 )
349 .await
350 .map_err(|_| {
351 SshMcpError::auth(format!(
352 "Authentication timed out after {}s",
353 AUTH_TIMEOUT_SECS
354 ))
355 })?
356 .map_err(|e| SshMcpError::auth(e.to_string()))?;
357
358 if auth_result.success() {
359 info!("Password authentication successful");
360 return Ok(());
361 } else {
362 return Err(SshMcpError::auth("Password authentication rejected"));
363 }
364 }
365
366 if let Some(ref key_content) = self.config.private_key {
368 debug!(
369 "Attempting key authentication for user '{}'",
370 self.config.username
371 );
372
373 let key = Arc::new(
375 russh::keys::PrivateKey::from_openssh(key_content.as_bytes()).map_err(|e| {
376 SshMcpError::SshKey(format!("Failed to parse private key: {}", e))
377 })?,
378 );
379
380 let hash_attempts: &[Option<HashAlg>] = if key.algorithm().is_rsa() {
383 &[Some(HashAlg::Sha256), Some(HashAlg::Sha512), None]
384 } else {
385 &[None]
386 };
387
388 for hash_alg in hash_attempts {
389 debug!(
390 alg = %key.algorithm(),
391 ?hash_alg,
392 "Attempting publickey authentication"
393 );
394
395 let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&key), *hash_alg);
396
397 let auth_result = timeout(
398 Duration::from_secs(AUTH_TIMEOUT_SECS),
399 session.authenticate_publickey(&self.config.username, key_with_alg),
400 )
401 .await
402 .map_err(|_| {
403 SshMcpError::auth(format!(
404 "Authentication timed out after {}s",
405 AUTH_TIMEOUT_SECS
406 ))
407 })?
408 .map_err(|e| SshMcpError::auth(e.to_string()))?;
409
410 if auth_result.success() {
411 info!("Key authentication successful");
412 return Ok(());
413 }
414 }
415
416 return Err(SshMcpError::auth("Key authentication rejected"));
417 }
418
419 Err(SshMcpError::auth(
420 "No authentication method available (require password or private_key)",
421 ))
422 }
423
424 pub async fn is_connected(&self) -> bool {
426 let session_guard = self.session.lock().await;
427 session_guard.is_some()
428 }
429
430 pub async fn ensure_connected(&self) -> Result<()> {
432 if !self.is_connected().await {
433 return self
434 .connect_with_retry("no active session found during ensure_connected")
435 .await;
436 }
437
438 if self.is_health_probe_fresh().await {
439 return Ok(());
440 }
441
442 let _probe_guard = self.health_probe_lock.lock().await;
443 if self.is_health_probe_fresh().await {
444 return Ok(());
445 }
446
447 if let Err(probe_error) = self.run_health_probe().await {
448 warn!(
449 error = ?probe_error,
450 "SSH health probe failed, invalidating session before reconnect"
451 );
452 self.invalidate_session("health probe failed").await;
453 self.connect_with_retry("health probe failed during ensure_connected")
454 .await?;
455 } else {
456 self.mark_health_probe_ok().await;
457 }
458
459 Ok(())
460 }
461
462 fn health_probe_ttl(&self) -> Duration {
463 let ttl_ms = self
464 .config
465 .health_probe_timeout_ms
466 .saturating_mul(2)
467 .clamp(MIN_HEALTH_PROBE_TTL_MS, MAX_HEALTH_PROBE_TTL_MS);
468 Duration::from_millis(ttl_ms)
469 }
470
471 async fn is_health_probe_fresh(&self) -> bool {
472 let guard = self.last_health_probe_ok_at.lock().await;
473 if let Some(last_ok_at) = guard.as_ref() {
474 return last_ok_at.elapsed() < self.health_probe_ttl();
475 }
476
477 false
478 }
479
480 async fn mark_health_probe_ok(&self) {
481 let mut guard = self.last_health_probe_ok_at.lock().await;
482 *guard = Some(tokio::time::Instant::now());
483 }
484
485 async fn run_health_probe(&self) -> Result<()> {
486 let ping_result = {
487 let session_guard = self.session.lock().await;
488 let session = session_guard
489 .as_ref()
490 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
491
492 timeout(
493 Duration::from_millis(self.config.health_probe_timeout_ms),
494 session.send_ping(),
495 )
496 .await
497 };
498
499 match ping_result {
500 Ok(Ok(())) => Ok(()),
501 Ok(Err(e)) => Err(SshMcpError::connection(format!(
502 "SSH health probe ping failed: {e}"
503 ))),
504 Err(_) => Err(SshMcpError::connection(format!(
505 "SSH health probe timed out after {}ms",
506 self.config.health_probe_timeout_ms
507 ))),
508 }
509 }
510
511 async fn connect_with_retry(&self, reason: &str) -> Result<()> {
512 let max_attempts = self.config.reconnect_retries.saturating_add(1);
513 let mut attempt: u64 = 1;
514 let mut last_error: Option<SshMcpError> = None;
515
516 while attempt <= max_attempts {
517 match self.connect().await {
518 Ok(()) => {
519 if attempt > 1 {
520 info!(
521 attempts = attempt,
522 reason = reason,
523 "SSH reconnect succeeded"
524 );
525 }
526 return Ok(());
527 }
528 Err(err) => {
529 let backoff_ms = self.backoff_for_attempt(attempt);
530 warn!(
531 attempt = attempt,
532 max_attempts = max_attempts,
533 backoff_ms = backoff_ms,
534 reason = reason,
535 error = ?err,
536 "SSH reconnect attempt failed"
537 );
538 last_error = Some(err);
539
540 if attempt < max_attempts && backoff_ms > 0 {
541 sleep(Duration::from_millis(backoff_ms)).await;
542 }
543 }
544 }
545
546 attempt = attempt.saturating_add(1);
547 }
548
549 if let Some(err) = last_error {
550 return Err(err);
551 }
552
553 Err(SshMcpError::connection(
554 "Reconnect retry loop ended without connection result",
555 ))
556 }
557
558 fn backoff_for_attempt(&self, attempt: u64) -> u64 {
559 let exponent = attempt.saturating_sub(1).min(63) as u32;
560 let factor = 1_u64 << exponent;
561 self.config
562 .reconnect_backoff_ms
563 .saturating_mul(factor)
564 .min(MAX_RECONNECT_BACKOFF_MS)
565 }
566
567 pub async fn with_session<F, T>(&self, f: F) -> Result<T>
572 where
573 F: FnOnce(&Handle<SshHandler>) -> T,
574 {
575 let session_guard = self.session.lock().await;
576 match session_guard.as_ref() {
577 Some(session) => Ok(f(session)),
578 None => Err(SshMcpError::connection("SSH connection not established")),
579 }
580 }
581
582 pub async fn open_channel(&self) -> Result<Channel<client::Msg>> {
584 let session_guard = self.session.lock().await;
585 let session = session_guard
586 .as_ref()
587 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
588
589 let channel = session
590 .channel_open_session()
591 .await
592 .map_err(|e| SshMcpError::connection(format!("Failed to open channel: {}", e)))?;
593
594 Ok(channel)
595 }
596
597 pub fn is_elevated(&self) -> bool {
599 self.is_elevated.load(Ordering::SeqCst)
600 }
601
602 pub fn use_timeout_wrapper(&self) -> bool {
607 self.has_timeout_cmd.load(Ordering::SeqCst)
608 }
609
610 pub fn disable_timeout_wrapper(&self) {
616 self.has_timeout_cmd.store(false, Ordering::SeqCst);
617 warn!("timeout wrapper disabled due to errors, falling back to pkill");
618 }
619
620 pub(crate) async fn determine_timeout_wrapper_usage(&self) -> bool {
625 if self.use_timeout_wrapper() {
626 return true;
627 }
628
629 let _ = self.check_timeout_availability().await;
630 self.use_timeout_wrapper()
631 }
632
633 pub async fn check_timeout_availability(&self) -> bool {
642 if self.has_timeout_cmd.load(Ordering::SeqCst) {
644 return true;
645 }
646
647 let mut channel = match self.open_channel().await {
649 Ok(ch) => ch,
650 Err(e) => {
651 debug!(error = ?e, "Failed to open channel for timeout detection");
652 return false;
653 }
654 };
655
656 let exec_result = channel
658 .exec(true, "sh -c 'command -v timeout'")
659 .await
660 .map_err(|e| {
661 SshMcpError::connection(format!("Failed to exec detection command: {}", e))
662 });
663
664 if exec_result.is_err() {
665 debug!("Failed to exec timeout detection command");
666 return false;
667 }
668
669 let mut output = String::new();
671 while let Some(msg) = channel.wait().await {
672 match msg {
673 ChannelMsg::Data { data } => {
674 output.push_str(&String::from_utf8_lossy(&data));
675 }
676 ChannelMsg::Close | ChannelMsg::Eof => {
677 break;
678 }
679 _ => {
680 }
682 }
683 }
684
685 let available = !output.is_empty();
687 self.has_timeout_cmd.store(available, Ordering::SeqCst);
688
689 if available {
690 info!("timeout command available on remote host");
691 } else {
692 info!("timeout command NOT available, using fallback pkill");
693 }
694
695 available
696 }
697
698 pub async fn has_su_channel(&self) -> bool {
700 let channel_guard = self.su_channel.lock().await;
701 channel_guard.is_some()
702 }
703
704 pub async fn with_su_channel<F, Fut, T>(&self, f: F) -> Result<T>
709 where
710 F: FnOnce(&mut Option<Channel<client::Msg>>) -> Fut,
711 Fut: std::future::Future<Output = Result<T>>,
712 {
713 let mut channel_guard = self.su_channel.lock().await;
714 f(&mut channel_guard).await
715 }
716
717 pub async fn ensure_elevated(&self) -> Result<()> {
722 if self.is_elevated.load(Ordering::SeqCst) {
724 let channel_guard = self.su_channel.lock().await;
725 if channel_guard.is_some() {
726 return Ok(());
727 }
728 }
729
730 let su_password = self
732 .config
733 .su_password
734 .clone()
735 .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
736
737 let channel = self
739 .open_channel()
740 .await
741 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to open channel: {}", e)))?;
742
743 debug!("Opened channel for su elevation");
744
745 channel
747 .request_pty(
748 true, "xterm",
750 80, 24, 0, 0, &[], )
756 .await
757 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
758
759 debug!("PTY requested");
760
761 channel.request_shell(true).await.map_err(|e| {
763 SshMcpError::elevation_failed(format!("Failed to request shell: {}", e))
764 })?;
765
766 debug!("Shell requested, starting su elevation...");
767
768 channel.data(b"su -\n".as_slice()).await.map_err(|e| {
770 SshMcpError::elevation_failed(format!("Failed to send su command: {}", e))
771 })?;
772
773 let elevation_result = self.handle_su_elevation(channel, &su_password).await;
775
776 match elevation_result {
777 Ok(elevated_channel) => {
778 let mut channel_guard = self.su_channel.lock().await;
780 *channel_guard = Some(elevated_channel);
781 self.is_elevated.store(true, Ordering::SeqCst);
782 info!("Successfully elevated to root via su");
783 Ok(())
784 }
785 Err(e) => {
786 self.is_elevated.store(false, Ordering::SeqCst);
787 Err(e)
788 }
789 }
790 }
791
792 async fn handle_su_elevation(
794 &self,
795 mut channel: Channel<client::Msg>,
796 password: &str,
797 ) -> Result<Channel<client::Msg>> {
798 use russh::ChannelMsg;
799
800 let elevation_timeout = Duration::from_secs(10);
801 let mut buffer = String::new();
802 let mut password_sent = false;
803
804 let deadline = tokio::time::Instant::now() + elevation_timeout;
805
806 loop {
807 if tokio::time::Instant::now() > deadline {
809 return Err(SshMcpError::elevation_failed("su elevation timed out"));
810 }
811
812 let wait_result =
814 tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
815
816 match wait_result {
817 Ok(Some(msg)) => {
818 match msg {
819 ChannelMsg::Data { data } => {
820 let text = String::from_utf8_lossy(&data);
821 buffer.push_str(&text);
822 debug!(su_buffer_len = buffer.len(), "su buffer received");
823
824 if !password_sent && buffer.to_lowercase().contains("password") {
826 debug!("Password prompt detected, sending password...");
827 channel
828 .data(format!("{}\n", password).as_bytes())
829 .await
830 .map_err(|e| {
831 SshMcpError::elevation_failed(format!(
832 "Failed to send password: {}",
833 e
834 ))
835 })?;
836 password_sent = true;
837 buffer.clear();
839 }
840
841 if password_sent && buffer.contains('#') {
843 debug!("Root prompt detected, elevation successful");
844 return Ok(channel);
845 }
846
847 if buffer.to_lowercase().contains("authentication failure")
849 || buffer.to_lowercase().contains("incorrect password")
850 || buffer.to_lowercase().contains("su: failed")
851 || buffer.to_lowercase().contains("su: authentication")
852 {
853 return Err(SshMcpError::elevation_failed(format!(
854 "su authentication failed: {}",
855 buffer
856 )));
857 }
858 }
859 ChannelMsg::Close => {
860 return Err(SshMcpError::elevation_failed(
861 "Channel closed before elevation completed",
862 ));
863 }
864 _ => {
865 }
867 }
868 }
869 Ok(None) => {
870 return Err(SshMcpError::elevation_failed(
872 "Channel ended before elevation completed",
873 ));
874 }
875 Err(_) => {
876 continue;
878 }
879 }
880 }
881 }
882
883 pub fn get_su_password(&self) -> Option<&str> {
885 self.config.su_password.as_deref()
886 }
887
888 pub fn get_sudo_password(&self) -> Option<&str> {
890 self.config.sudo_password.as_deref()
891 }
892
893 pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
898 if password.is_some() {
904 self.ensure_elevated().await?;
907 } else {
908 let mut channel_guard = self.su_channel.lock().await;
910 if let Some(ch) = channel_guard.take() {
911 let _ = ch.eof().await;
913 }
914 self.is_elevated.store(false, Ordering::SeqCst);
915 }
916
917 Ok(())
918 }
919
920 pub async fn close(&self) {
922 {
924 let mut channel_guard = self.su_channel.lock().await;
925 if let Some(ch) = channel_guard.take() {
926 let _ = ch.eof().await;
927 }
928 }
929 self.is_elevated.store(false, Ordering::SeqCst);
930
931 {
933 let mut session_guard = self.session.lock().await;
934 if let Some(session) = session_guard.take() {
935 let _ = session
936 .disconnect(russh::Disconnect::ByApplication, "", "")
937 .await;
938 }
939 }
940
941 {
942 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
943 *probe_guard = None;
944 }
945
946 info!("SSH connection closed");
947 }
948
949 pub async fn invalidate_session(&self, reason: &str) {
954 warn!(reason = ?reason, "Invalidating SSH session");
955
956 let channel = {
958 let mut channel_guard = self.su_channel.lock().await;
959 channel_guard.take()
960 };
961
962 if let Some(ch) = channel {
964 let _ = ch.eof().await;
965 }
966 self.is_elevated.store(false, Ordering::SeqCst);
967
968 let session = {
970 let mut session_guard = self.session.lock().await;
971 session_guard.take()
972 };
973
974 if let Some(session) = session {
975 let _ = tokio::time::timeout(
976 Duration::from_millis(500),
977 session.disconnect(russh::Disconnect::ByApplication, "", ""),
978 )
979 .await;
980 }
981
982 {
983 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
984 *probe_guard = None;
985 }
986
987 debug!(reason = ?reason, "Session invalidated");
988 }
989
990 pub async fn reconnect(&self) -> Result<()> {
996 self.invalidate_session("explicit reconnect requested")
997 .await;
998 self.connect_with_retry("explicit reconnect requested")
999 .await
1000 }
1001}
1002
1003impl std::fmt::Debug for SshConnectionManager {
1004 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1005 f.debug_struct("SshConnectionManager")
1006 .field("host", &self.config.host)
1007 .field("port", &self.config.port)
1008 .field("username", &self.config.username)
1009 .field("is_connecting", &self.is_connecting.load(Ordering::SeqCst))
1010 .field("is_elevated", &self.is_elevated.load(Ordering::SeqCst))
1011 .field(
1012 "has_timeout_cmd",
1013 &self.has_timeout_cmd.load(Ordering::SeqCst),
1014 )
1015 .finish()
1016 }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022
1023 #[tokio::test]
1024 async fn test_connection_manager_creation() {
1025 let config = SshConfig::new("localhost", "testuser")
1026 .with_port(22)
1027 .with_password("testpass");
1028
1029 let manager = SshConnectionManager::new(config).await;
1030
1031 assert!(!manager.is_connected().await);
1032 assert!(!manager.is_elevated());
1033 }
1034
1035 #[tokio::test]
1036 async fn test_not_connected_initially() {
1037 let config = SshConfig::new("localhost", "testuser");
1038 let manager = SshConnectionManager::new(config).await;
1039
1040 let result = manager.open_channel().await;
1042 assert!(result.is_err());
1043 }
1044
1045 #[tokio::test]
1046 async fn test_cancelled_connect_releases_owner_flag() {
1047 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1048 .await
1049 .expect("bind test listener");
1050 let port = listener.local_addr().expect("test listener address").port();
1051 let accept_task = tokio::spawn(async move {
1052 let (_stream, _) = listener.accept().await.expect("accept test connection");
1053 std::future::pending::<()>().await;
1054 });
1055 let config = SshConfig::new("127.0.0.1", "testuser")
1056 .with_port(port)
1057 .with_password("testpass");
1058 let manager = SshConnectionManager::new(config).await;
1059
1060 let result = timeout(Duration::from_millis(100), manager.connect()).await;
1061 assert!(result.is_err(), "silent peer should keep connect in flight");
1062 assert!(
1063 !manager.is_connecting.load(Ordering::SeqCst),
1064 "cancelling connect must release the owner flag"
1065 );
1066
1067 accept_task.abort();
1068 }
1069}