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
34pub struct SshConnectionManager {
42 pub(crate) config: SshConfig,
45
46 session: Arc<Mutex<Option<Handle<SshHandler>>>>,
48
49 is_connecting: AtomicBool,
51
52 connect_notify: Arc<Notify>,
54
55 pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
58
59 pub(crate) is_elevated: AtomicBool,
62
63 has_timeout_cmd: AtomicBool,
65
66 pub(crate) channel_semaphore: Arc<Semaphore>,
69
70 last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
72
73 health_probe_lock: Arc<Mutex<()>>,
75}
76
77impl SshConnectionManager {
78 pub async fn new(config: SshConfig) -> Self {
83 Self {
84 config,
85 session: Arc::new(Mutex::new(None)),
86 is_connecting: AtomicBool::new(false),
87 connect_notify: Arc::new(Notify::new()),
88 su_channel: Arc::new(Mutex::new(None)),
89 is_elevated: AtomicBool::new(false),
90 has_timeout_cmd: AtomicBool::new(false),
91 channel_semaphore: Arc::new(Semaphore::new(CHANNEL_SEMAPHORE_CAPACITY)),
92 last_health_probe_ok_at: Arc::new(Mutex::new(None)),
93 health_probe_lock: Arc::new(Mutex::new(())),
94 }
95 }
96
97 pub(crate) async fn acquire_command_slot_raw(
98 &self,
99 ) -> std::result::Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
100 self.channel_semaphore.clone().acquire_owned().await
101 }
102
103 pub(crate) async fn acquire_command_slot(&self) -> Result<OwnedSemaphorePermit> {
104 self.acquire_command_slot_raw()
105 .await
106 .map_err(|e| SshMcpError::connection(format!("Failed to acquire command slot: {e}")))
107 }
108
109 pub async fn connect(&self) -> Result<()> {
114 if self.is_connected().await {
116 debug!("Already connected to SSH server");
117 return Ok(());
118 }
119
120 if self
122 .is_connecting
123 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
124 .is_err()
125 {
126 debug!("Another connection attempt in progress, waiting...");
127 let wait_result = timeout(
129 Duration::from_secs(CONNECT_WAIT_TIMEOUT_SECS),
130 self.connect_notify.notified(),
131 )
132 .await;
133 if wait_result.is_err() {
134 warn!(
135 "Timed out waiting for in-flight connection attempt after {}s",
136 CONNECT_WAIT_TIMEOUT_SECS
137 );
138 return Err(SshMcpError::connection(format!(
139 "Timed out waiting for in-flight connection attempt after {}s",
140 CONNECT_WAIT_TIMEOUT_SECS
141 )));
142 }
143 return if self.is_connected().await {
144 Ok(())
145 } else {
146 Err(SshMcpError::connection("Connection failed by another task"))
147 };
148 }
149
150 let result = self.do_connect().await;
152
153 self.is_connecting.store(false, Ordering::SeqCst);
155 self.connect_notify.notify_waiters();
156
157 result
158 }
159
160 async fn do_connect(&self) -> Result<()> {
166 info!(
167 "Connecting to SSH server {}:{}...",
168 self.config.host, self.config.port
169 );
170
171 let connection_timeout = Duration::from_secs(CONNECTION_TIMEOUT_SECS);
172
173 let ssh_config = Arc::new(client::Config {
174 keepalive_interval: Some(Duration::from_secs(self.config.keepalive_interval)),
175 keepalive_max: self.config.keepalive_max as usize,
176 ..Default::default()
177 });
178
179 let addr = format!("{}:{}", self.config.host, self.config.port);
180
181 let key_outcome = Arc::new(std::sync::Mutex::new(None::<KeyCheckOutcome>));
183 let handler = SshHandler::new(
184 self.config.host.clone(),
185 self.config.port,
186 self.config.host_key_checking,
187 self.config.known_hosts.clone(),
188 )
189 .with_key_check_outcome(key_outcome.clone());
190
191 match self
192 .attempt_connect(&ssh_config, &addr, handler, connection_timeout)
193 .await
194 {
195 Ok(session) => self.finish_connect(session).await,
196 Err(first_err) => {
197 let outcome = key_outcome.lock().unwrap().take();
200 if matches!(outcome, Some(KeyCheckOutcome::KeyChanged))
201 && self.config.host_key_checking == HostKeyCheckMode::AcceptNew
202 {
203 let Some(path) = self.resolve_known_hosts_path() else {
204 error!(
205 host = %self.config.host,
206 port = self.config.port,
207 "Host key changed but cannot resolve known_hosts path for recovery"
208 );
209 return Err(first_err);
210 };
211
212 warn!(
213 host = %self.config.host,
214 port = self.config.port,
215 path = %path.display(),
216 "Host key changed in accept-new mode; \
217 removing stale known_hosts entry and retrying once"
218 );
219 remove_known_hosts_entry(&self.config.host, self.config.port, &path).map_err(
220 |e| {
221 SshMcpError::connection(format!(
222 "Failed to remove stale known_hosts entry: {e}"
223 ))
224 },
225 )?;
226
227 let retry_handler = SshHandler::new(
229 self.config.host.clone(),
230 self.config.port,
231 self.config.host_key_checking,
232 self.config.known_hosts.clone(),
233 );
234 match self
235 .attempt_connect(&ssh_config, &addr, retry_handler, connection_timeout)
236 .await
237 {
238 Ok(session) => {
239 info!(
240 host = %self.config.host,
241 port = self.config.port,
242 "SSH reconnection succeeded after host key rotation"
243 );
244 self.finish_connect(session).await
245 }
246 Err(retry_err) => {
247 error!(
248 error = ?retry_err,
249 "SSH connection failed on retry after key rotation"
250 );
251 Err(retry_err)
252 }
253 }
254 } else {
255 error!(error = ?first_err, "SSH connection failed");
256 Err(first_err)
257 }
258 }
259 }
260 }
261
262 async fn attempt_connect(
264 &self,
265 ssh_config: &Arc<client::Config>,
266 addr: &str,
267 handler: SshHandler,
268 connection_timeout: Duration,
269 ) -> Result<Handle<SshHandler>> {
270 timeout(
271 connection_timeout,
272 client::connect(ssh_config.clone(), addr, handler),
273 )
274 .await
275 .map_err(|_| {
276 error!("SSH connection timeout after {}s", CONNECTION_TIMEOUT_SECS);
277 SshMcpError::connection(format!(
278 "Connection timeout after {}s",
279 CONNECTION_TIMEOUT_SECS
280 ))
281 })?
282 .map_err(|e| SshMcpError::connection(e.to_string()))
283 }
284
285 async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
287 self.authenticate(&mut session).await?;
289
290 {
292 let mut session_guard = self.session.lock().await;
293 *session_guard = Some(session);
294 }
295 {
296 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
297 *probe_guard = None;
298 }
299
300 info!(
301 "Successfully connected to {}@{}:{}",
302 self.config.username, self.config.host, self.config.port
303 );
304
305 if self.config.su_password.is_some() {
307 debug!("su_password configured, attempting elevation...");
308 if let Err(e) = self.ensure_elevated().await {
309 warn!(error = ?e, "Failed to elevate to root. Commands will run as normal user.");
310 }
311 }
312
313 Ok(())
314 }
315
316 fn resolve_known_hosts_path(&self) -> Option<PathBuf> {
318 self.config
319 .known_hosts
320 .clone()
321 .or_else(default_known_hosts_path)
322 }
323
324 async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
326 if let Some(ref password) = self.config.password {
328 debug!(
329 "Attempting password authentication for user '{}'",
330 self.config.username
331 );
332 let auth_result = timeout(
333 Duration::from_secs(AUTH_TIMEOUT_SECS),
334 session.authenticate_password(&self.config.username, password),
335 )
336 .await
337 .map_err(|_| {
338 SshMcpError::auth(format!(
339 "Authentication timed out after {}s",
340 AUTH_TIMEOUT_SECS
341 ))
342 })?
343 .map_err(|e| SshMcpError::auth(e.to_string()))?;
344
345 if auth_result.success() {
346 info!("Password authentication successful");
347 return Ok(());
348 } else {
349 return Err(SshMcpError::auth("Password authentication rejected"));
350 }
351 }
352
353 if let Some(ref key_content) = self.config.private_key {
355 debug!(
356 "Attempting key authentication for user '{}'",
357 self.config.username
358 );
359
360 let key = Arc::new(
362 russh::keys::PrivateKey::from_openssh(key_content.as_bytes()).map_err(|e| {
363 SshMcpError::SshKey(format!("Failed to parse private key: {}", e))
364 })?,
365 );
366
367 let hash_attempts: &[Option<HashAlg>] = if key.algorithm().is_rsa() {
370 &[Some(HashAlg::Sha256), Some(HashAlg::Sha512), None]
371 } else {
372 &[None]
373 };
374
375 for hash_alg in hash_attempts {
376 debug!(
377 alg = %key.algorithm(),
378 ?hash_alg,
379 "Attempting publickey authentication"
380 );
381
382 let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&key), *hash_alg);
383
384 let auth_result = timeout(
385 Duration::from_secs(AUTH_TIMEOUT_SECS),
386 session.authenticate_publickey(&self.config.username, key_with_alg),
387 )
388 .await
389 .map_err(|_| {
390 SshMcpError::auth(format!(
391 "Authentication timed out after {}s",
392 AUTH_TIMEOUT_SECS
393 ))
394 })?
395 .map_err(|e| SshMcpError::auth(e.to_string()))?;
396
397 if auth_result.success() {
398 info!("Key authentication successful");
399 return Ok(());
400 }
401 }
402
403 return Err(SshMcpError::auth("Key authentication rejected"));
404 }
405
406 Err(SshMcpError::auth(
407 "No authentication method available (require password or private_key)",
408 ))
409 }
410
411 pub async fn is_connected(&self) -> bool {
413 let session_guard = self.session.lock().await;
414 session_guard.is_some()
415 }
416
417 pub async fn ensure_connected(&self) -> Result<()> {
419 if !self.is_connected().await {
420 return self
421 .connect_with_retry("no active session found during ensure_connected")
422 .await;
423 }
424
425 if self.is_health_probe_fresh().await {
426 return Ok(());
427 }
428
429 let _probe_guard = self.health_probe_lock.lock().await;
430 if self.is_health_probe_fresh().await {
431 return Ok(());
432 }
433
434 if let Err(probe_error) = self.run_health_probe().await {
435 warn!(
436 error = ?probe_error,
437 "SSH health probe failed, invalidating session before reconnect"
438 );
439 self.invalidate_session("health probe failed").await;
440 self.connect_with_retry("health probe failed during ensure_connected")
441 .await?;
442 } else {
443 self.mark_health_probe_ok().await;
444 }
445
446 Ok(())
447 }
448
449 fn health_probe_ttl(&self) -> Duration {
450 let ttl_ms = self
451 .config
452 .health_probe_timeout_ms
453 .saturating_mul(2)
454 .clamp(MIN_HEALTH_PROBE_TTL_MS, MAX_HEALTH_PROBE_TTL_MS);
455 Duration::from_millis(ttl_ms)
456 }
457
458 async fn is_health_probe_fresh(&self) -> bool {
459 let guard = self.last_health_probe_ok_at.lock().await;
460 if let Some(last_ok_at) = guard.as_ref() {
461 return last_ok_at.elapsed() < self.health_probe_ttl();
462 }
463
464 false
465 }
466
467 async fn mark_health_probe_ok(&self) {
468 let mut guard = self.last_health_probe_ok_at.lock().await;
469 *guard = Some(tokio::time::Instant::now());
470 }
471
472 async fn run_health_probe(&self) -> Result<()> {
473 let ping_result = {
474 let session_guard = self.session.lock().await;
475 let session = session_guard
476 .as_ref()
477 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
478
479 timeout(
480 Duration::from_millis(self.config.health_probe_timeout_ms),
481 session.send_ping(),
482 )
483 .await
484 };
485
486 match ping_result {
487 Ok(Ok(())) => Ok(()),
488 Ok(Err(e)) => Err(SshMcpError::connection(format!(
489 "SSH health probe ping failed: {e}"
490 ))),
491 Err(_) => Err(SshMcpError::connection(format!(
492 "SSH health probe timed out after {}ms",
493 self.config.health_probe_timeout_ms
494 ))),
495 }
496 }
497
498 async fn connect_with_retry(&self, reason: &str) -> Result<()> {
499 let max_attempts = self.config.reconnect_retries.saturating_add(1);
500 let mut attempt: u64 = 1;
501 let mut last_error: Option<SshMcpError> = None;
502
503 while attempt <= max_attempts {
504 match self.connect().await {
505 Ok(()) => {
506 if attempt > 1 {
507 info!(
508 attempts = attempt,
509 reason = reason,
510 "SSH reconnect succeeded"
511 );
512 }
513 return Ok(());
514 }
515 Err(err) => {
516 let backoff_ms = self.backoff_for_attempt(attempt);
517 warn!(
518 attempt = attempt,
519 max_attempts = max_attempts,
520 backoff_ms = backoff_ms,
521 reason = reason,
522 error = ?err,
523 "SSH reconnect attempt failed"
524 );
525 last_error = Some(err);
526
527 if attempt < max_attempts && backoff_ms > 0 {
528 sleep(Duration::from_millis(backoff_ms)).await;
529 }
530 }
531 }
532
533 attempt = attempt.saturating_add(1);
534 }
535
536 if let Some(err) = last_error {
537 return Err(err);
538 }
539
540 Err(SshMcpError::connection(
541 "Reconnect retry loop ended without connection result",
542 ))
543 }
544
545 fn backoff_for_attempt(&self, attempt: u64) -> u64 {
546 let exponent = attempt.saturating_sub(1).min(63) as u32;
547 let factor = 1_u64 << exponent;
548 self.config
549 .reconnect_backoff_ms
550 .saturating_mul(factor)
551 .min(MAX_RECONNECT_BACKOFF_MS)
552 }
553
554 pub async fn with_session<F, T>(&self, f: F) -> Result<T>
559 where
560 F: FnOnce(&Handle<SshHandler>) -> T,
561 {
562 let session_guard = self.session.lock().await;
563 match session_guard.as_ref() {
564 Some(session) => Ok(f(session)),
565 None => Err(SshMcpError::connection("SSH connection not established")),
566 }
567 }
568
569 pub async fn open_channel(&self) -> Result<Channel<client::Msg>> {
571 let session_guard = self.session.lock().await;
572 let session = session_guard
573 .as_ref()
574 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
575
576 let channel = session
577 .channel_open_session()
578 .await
579 .map_err(|e| SshMcpError::connection(format!("Failed to open channel: {}", e)))?;
580
581 Ok(channel)
582 }
583
584 pub fn is_elevated(&self) -> bool {
586 self.is_elevated.load(Ordering::SeqCst)
587 }
588
589 pub fn use_timeout_wrapper(&self) -> bool {
594 self.has_timeout_cmd.load(Ordering::SeqCst)
595 }
596
597 pub fn disable_timeout_wrapper(&self) {
603 self.has_timeout_cmd.store(false, Ordering::SeqCst);
604 warn!("timeout wrapper disabled due to errors, falling back to pkill");
605 }
606
607 pub(crate) async fn determine_timeout_wrapper_usage(&self) -> bool {
612 if self.use_timeout_wrapper() {
613 return true;
614 }
615
616 let _ = self.check_timeout_availability().await;
617 self.use_timeout_wrapper()
618 }
619
620 pub async fn check_timeout_availability(&self) -> bool {
629 if self.has_timeout_cmd.load(Ordering::SeqCst) {
631 return true;
632 }
633
634 let mut channel = match self.open_channel().await {
636 Ok(ch) => ch,
637 Err(e) => {
638 debug!(error = ?e, "Failed to open channel for timeout detection");
639 return false;
640 }
641 };
642
643 let exec_result = channel
645 .exec(true, "sh -c 'command -v timeout'")
646 .await
647 .map_err(|e| {
648 SshMcpError::connection(format!("Failed to exec detection command: {}", e))
649 });
650
651 if exec_result.is_err() {
652 debug!("Failed to exec timeout detection command");
653 return false;
654 }
655
656 let mut output = String::new();
658 while let Some(msg) = channel.wait().await {
659 match msg {
660 ChannelMsg::Data { data } => {
661 output.push_str(&String::from_utf8_lossy(&data));
662 }
663 ChannelMsg::Close | ChannelMsg::Eof => {
664 break;
665 }
666 _ => {
667 }
669 }
670 }
671
672 let available = !output.is_empty();
674 self.has_timeout_cmd.store(available, Ordering::SeqCst);
675
676 if available {
677 info!("timeout command available on remote host");
678 } else {
679 info!("timeout command NOT available, using fallback pkill");
680 }
681
682 available
683 }
684
685 pub async fn has_su_channel(&self) -> bool {
687 let channel_guard = self.su_channel.lock().await;
688 channel_guard.is_some()
689 }
690
691 pub async fn with_su_channel<F, Fut, T>(&self, f: F) -> Result<T>
696 where
697 F: FnOnce(&mut Option<Channel<client::Msg>>) -> Fut,
698 Fut: std::future::Future<Output = Result<T>>,
699 {
700 let mut channel_guard = self.su_channel.lock().await;
701 f(&mut channel_guard).await
702 }
703
704 pub async fn ensure_elevated(&self) -> Result<()> {
709 if self.is_elevated.load(Ordering::SeqCst) {
711 let channel_guard = self.su_channel.lock().await;
712 if channel_guard.is_some() {
713 return Ok(());
714 }
715 }
716
717 let su_password = self
719 .config
720 .su_password
721 .clone()
722 .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
723
724 let channel = self
726 .open_channel()
727 .await
728 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to open channel: {}", e)))?;
729
730 debug!("Opened channel for su elevation");
731
732 channel
734 .request_pty(
735 true, "xterm",
737 80, 24, 0, 0, &[], )
743 .await
744 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
745
746 debug!("PTY requested");
747
748 channel.request_shell(true).await.map_err(|e| {
750 SshMcpError::elevation_failed(format!("Failed to request shell: {}", e))
751 })?;
752
753 debug!("Shell requested, starting su elevation...");
754
755 channel.data(b"su -\n".as_slice()).await.map_err(|e| {
757 SshMcpError::elevation_failed(format!("Failed to send su command: {}", e))
758 })?;
759
760 let elevation_result = self.handle_su_elevation(channel, &su_password).await;
762
763 match elevation_result {
764 Ok(elevated_channel) => {
765 let mut channel_guard = self.su_channel.lock().await;
767 *channel_guard = Some(elevated_channel);
768 self.is_elevated.store(true, Ordering::SeqCst);
769 info!("Successfully elevated to root via su");
770 Ok(())
771 }
772 Err(e) => {
773 self.is_elevated.store(false, Ordering::SeqCst);
774 Err(e)
775 }
776 }
777 }
778
779 async fn handle_su_elevation(
781 &self,
782 mut channel: Channel<client::Msg>,
783 password: &str,
784 ) -> Result<Channel<client::Msg>> {
785 use russh::ChannelMsg;
786
787 let elevation_timeout = Duration::from_secs(10);
788 let mut buffer = String::new();
789 let mut password_sent = false;
790
791 let deadline = tokio::time::Instant::now() + elevation_timeout;
792
793 loop {
794 if tokio::time::Instant::now() > deadline {
796 return Err(SshMcpError::elevation_failed("su elevation timed out"));
797 }
798
799 let wait_result =
801 tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
802
803 match wait_result {
804 Ok(Some(msg)) => {
805 match msg {
806 ChannelMsg::Data { data } => {
807 let text = String::from_utf8_lossy(&data);
808 buffer.push_str(&text);
809 debug!(su_buffer_len = buffer.len(), "su buffer received");
810
811 if !password_sent && buffer.to_lowercase().contains("password") {
813 debug!("Password prompt detected, sending password...");
814 channel
815 .data(format!("{}\n", password).as_bytes())
816 .await
817 .map_err(|e| {
818 SshMcpError::elevation_failed(format!(
819 "Failed to send password: {}",
820 e
821 ))
822 })?;
823 password_sent = true;
824 buffer.clear();
826 }
827
828 if password_sent && buffer.contains('#') {
830 debug!("Root prompt detected, elevation successful");
831 return Ok(channel);
832 }
833
834 if buffer.to_lowercase().contains("authentication failure")
836 || buffer.to_lowercase().contains("incorrect password")
837 || buffer.to_lowercase().contains("su: failed")
838 || buffer.to_lowercase().contains("su: authentication")
839 {
840 return Err(SshMcpError::elevation_failed(format!(
841 "su authentication failed: {}",
842 buffer
843 )));
844 }
845 }
846 ChannelMsg::Close => {
847 return Err(SshMcpError::elevation_failed(
848 "Channel closed before elevation completed",
849 ));
850 }
851 _ => {
852 }
854 }
855 }
856 Ok(None) => {
857 return Err(SshMcpError::elevation_failed(
859 "Channel ended before elevation completed",
860 ));
861 }
862 Err(_) => {
863 continue;
865 }
866 }
867 }
868 }
869
870 pub fn get_su_password(&self) -> Option<&str> {
872 self.config.su_password.as_deref()
873 }
874
875 pub fn get_sudo_password(&self) -> Option<&str> {
877 self.config.sudo_password.as_deref()
878 }
879
880 pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
885 if password.is_some() {
891 self.ensure_elevated().await?;
894 } else {
895 let mut channel_guard = self.su_channel.lock().await;
897 if let Some(ch) = channel_guard.take() {
898 let _ = ch.eof().await;
900 }
901 self.is_elevated.store(false, Ordering::SeqCst);
902 }
903
904 Ok(())
905 }
906
907 pub async fn close(&self) {
909 {
911 let mut channel_guard = self.su_channel.lock().await;
912 if let Some(ch) = channel_guard.take() {
913 let _ = ch.eof().await;
914 }
915 }
916 self.is_elevated.store(false, Ordering::SeqCst);
917
918 {
920 let mut session_guard = self.session.lock().await;
921 if let Some(session) = session_guard.take() {
922 let _ = session
923 .disconnect(russh::Disconnect::ByApplication, "", "")
924 .await;
925 }
926 }
927
928 {
929 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
930 *probe_guard = None;
931 }
932
933 info!("SSH connection closed");
934 }
935
936 pub async fn invalidate_session(&self, reason: &str) {
941 warn!(reason = ?reason, "Invalidating SSH session");
942
943 let channel = {
945 let mut channel_guard = self.su_channel.lock().await;
946 channel_guard.take()
947 };
948
949 if let Some(ch) = channel {
951 let _ = ch.eof().await;
952 }
953 self.is_elevated.store(false, Ordering::SeqCst);
954
955 let session = {
957 let mut session_guard = self.session.lock().await;
958 session_guard.take()
959 };
960
961 if let Some(session) = session {
962 let _ = tokio::time::timeout(
963 Duration::from_millis(500),
964 session.disconnect(russh::Disconnect::ByApplication, "", ""),
965 )
966 .await;
967 }
968
969 {
970 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
971 *probe_guard = None;
972 }
973
974 debug!(reason = ?reason, "Session invalidated");
975 }
976
977 pub async fn reconnect(&self) -> Result<()> {
983 self.invalidate_session("explicit reconnect requested")
984 .await;
985 self.connect_with_retry("explicit reconnect requested")
986 .await
987 }
988}
989
990impl std::fmt::Debug for SshConnectionManager {
991 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992 f.debug_struct("SshConnectionManager")
993 .field("host", &self.config.host)
994 .field("port", &self.config.port)
995 .field("username", &self.config.username)
996 .field("is_connecting", &self.is_connecting.load(Ordering::SeqCst))
997 .field("is_elevated", &self.is_elevated.load(Ordering::SeqCst))
998 .field(
999 "has_timeout_cmd",
1000 &self.has_timeout_cmd.load(Ordering::SeqCst),
1001 )
1002 .finish()
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 #[tokio::test]
1011 async fn test_connection_manager_creation() {
1012 let config = SshConfig::new("localhost", "testuser")
1013 .with_port(22)
1014 .with_password("testpass");
1015
1016 let manager = SshConnectionManager::new(config).await;
1017
1018 assert!(!manager.is_connected().await);
1019 assert!(!manager.is_elevated());
1020 }
1021
1022 #[tokio::test]
1023 async fn test_not_connected_initially() {
1024 let config = SshConfig::new("localhost", "testuser");
1025 let manager = SshConnectionManager::new(config).await;
1026
1027 let result = manager.open_channel().await;
1029 assert!(result.is_err());
1030 }
1031}