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 shutting_down: AtomicBool,
66
67 connect_notify: Arc<Notify>,
69
70 pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
73
74 pub(crate) is_elevated: AtomicBool,
77
78 has_timeout_cmd: AtomicBool,
80
81 pub(crate) channel_semaphore: Arc<Semaphore>,
84
85 last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
87
88 health_probe_lock: Arc<Mutex<()>>,
90}
91
92impl SshConnectionManager {
93 pub async fn new(config: SshConfig) -> Self {
98 Self {
99 config,
100 session: Arc::new(Mutex::new(None)),
101 is_connecting: AtomicBool::new(false),
102 shutting_down: AtomicBool::new(false),
103 connect_notify: Arc::new(Notify::new()),
104 su_channel: Arc::new(Mutex::new(None)),
105 is_elevated: AtomicBool::new(false),
106 has_timeout_cmd: AtomicBool::new(false),
107 channel_semaphore: Arc::new(Semaphore::new(CHANNEL_SEMAPHORE_CAPACITY)),
108 last_health_probe_ok_at: Arc::new(Mutex::new(None)),
109 health_probe_lock: Arc::new(Mutex::new(())),
110 }
111 }
112
113 pub(crate) async fn acquire_command_slot_raw(
114 &self,
115 ) -> std::result::Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
116 self.channel_semaphore.clone().acquire_owned().await
117 }
118
119 pub(crate) async fn acquire_command_slot(&self) -> Result<OwnedSemaphorePermit> {
120 self.acquire_command_slot_raw()
121 .await
122 .map_err(|e| SshMcpError::connection(format!("Failed to acquire command slot: {e}")))
123 }
124
125 pub(crate) fn is_shutting_down(&self) -> bool {
126 self.shutting_down.load(Ordering::SeqCst)
127 }
128
129 fn ensure_not_shutting_down(&self) -> Result<()> {
130 if self.is_shutting_down() {
131 Err(SshMcpError::connection(
132 "SSH connection manager is shutting down",
133 ))
134 } else {
135 Ok(())
136 }
137 }
138
139 pub async fn connect(&self) -> Result<()> {
144 self.ensure_not_shutting_down()?;
145
146 if self.is_connected().await {
148 debug!("Already connected to SSH server");
149 return Ok(());
150 }
151
152 let notified = self.connect_notify.notified();
154 tokio::pin!(notified);
155 notified.as_mut().enable();
156
157 if self
159 .is_connecting
160 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
161 .is_err()
162 {
163 debug!("Another connection attempt in progress, waiting...");
164 let wait_result =
166 timeout(Duration::from_secs(CONNECT_WAIT_TIMEOUT_SECS), notified).await;
167 if wait_result.is_err() {
168 warn!(
169 "Timed out waiting for in-flight connection attempt after {}s",
170 CONNECT_WAIT_TIMEOUT_SECS
171 );
172 return Err(SshMcpError::connection(format!(
173 "Timed out waiting for in-flight connection attempt after {}s",
174 CONNECT_WAIT_TIMEOUT_SECS
175 )));
176 }
177 return if self.is_shutting_down() {
178 self.ensure_not_shutting_down()
179 } else if self.is_connected().await {
180 Ok(())
181 } else {
182 Err(SshMcpError::connection("Connection failed by another task"))
183 };
184 }
185
186 let _attempt_guard = ConnectAttemptGuard {
188 is_connecting: &self.is_connecting,
189 connect_notify: self.connect_notify.as_ref(),
190 };
191
192 self.do_connect().await
193 }
194
195 async fn do_connect(&self) -> Result<()> {
201 info!(
202 "Connecting to SSH server {}:{}...",
203 self.config.host, self.config.port
204 );
205
206 let connection_timeout = Duration::from_secs(CONNECTION_TIMEOUT_SECS);
207
208 let ssh_config = Arc::new(client::Config {
209 keepalive_interval: Some(Duration::from_secs(self.config.keepalive_interval)),
210 keepalive_max: self.config.keepalive_max as usize,
211 ..Default::default()
212 });
213
214 let addr = format!("{}:{}", self.config.host, self.config.port);
215
216 let key_outcome = Arc::new(std::sync::Mutex::new(None::<KeyCheckOutcome>));
218 let handler = SshHandler::new(
219 self.config.host.clone(),
220 self.config.port,
221 self.config.host_key_checking,
222 self.config.known_hosts.clone(),
223 )
224 .with_key_check_outcome(key_outcome.clone());
225
226 match self
227 .attempt_connect(&ssh_config, &addr, handler, connection_timeout)
228 .await
229 {
230 Ok(session) => self.finish_connect(session).await,
231 Err(first_err) => {
232 let outcome = key_outcome.lock().unwrap().take();
235 if matches!(outcome, Some(KeyCheckOutcome::KeyChanged))
236 && self.config.host_key_checking == HostKeyCheckMode::AcceptNew
237 {
238 let Some(path) = self.resolve_known_hosts_path() else {
239 error!(
240 host = %self.config.host,
241 port = self.config.port,
242 "Host key changed but cannot resolve known_hosts path for recovery"
243 );
244 return Err(first_err);
245 };
246
247 warn!(
248 host = %self.config.host,
249 port = self.config.port,
250 path = %path.display(),
251 "Host key changed in accept-new mode; \
252 removing stale known_hosts entry and retrying once"
253 );
254 remove_known_hosts_entry(&self.config.host, self.config.port, &path).map_err(
255 |e| {
256 SshMcpError::connection(format!(
257 "Failed to remove stale known_hosts entry: {e}"
258 ))
259 },
260 )?;
261
262 let retry_handler = SshHandler::new(
264 self.config.host.clone(),
265 self.config.port,
266 self.config.host_key_checking,
267 self.config.known_hosts.clone(),
268 );
269 match self
270 .attempt_connect(&ssh_config, &addr, retry_handler, connection_timeout)
271 .await
272 {
273 Ok(session) => {
274 info!(
275 host = %self.config.host,
276 port = self.config.port,
277 "SSH reconnection succeeded after host key rotation"
278 );
279 self.finish_connect(session).await
280 }
281 Err(retry_err) => {
282 error!(
283 error = ?retry_err,
284 "SSH connection failed on retry after key rotation"
285 );
286 Err(retry_err)
287 }
288 }
289 } else {
290 error!(error = ?first_err, "SSH connection failed");
291 Err(first_err)
292 }
293 }
294 }
295 }
296
297 async fn attempt_connect(
299 &self,
300 ssh_config: &Arc<client::Config>,
301 addr: &str,
302 handler: SshHandler,
303 connection_timeout: Duration,
304 ) -> Result<Handle<SshHandler>> {
305 timeout(
306 connection_timeout,
307 client::connect(ssh_config.clone(), addr, handler),
308 )
309 .await
310 .map_err(|_| {
311 error!("SSH connection timeout after {}s", CONNECTION_TIMEOUT_SECS);
312 SshMcpError::connection(format!(
313 "Connection timeout after {}s",
314 CONNECTION_TIMEOUT_SECS
315 ))
316 })?
317 .map_err(|e| SshMcpError::connection(e.to_string()))
318 }
319
320 async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
322 self.authenticate(&mut session).await?;
324
325 let mut session = Some(session);
327 {
328 let mut session_guard = self.session.lock().await;
329 if !self.is_shutting_down() {
330 *session_guard = session.take();
331 }
332 }
333 if let Some(session) = session {
334 let _ = session
335 .disconnect(russh::Disconnect::ByApplication, "", "")
336 .await;
337 return self.ensure_not_shutting_down();
338 }
339 {
340 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
341 *probe_guard = None;
342 }
343
344 info!(
345 "Successfully connected to {}@{}:{}",
346 self.config.username, self.config.host, self.config.port
347 );
348
349 if self.config.su_password.is_some() {
351 debug!("su_password configured, attempting elevation...");
352 if let Err(e) = self.ensure_elevated().await {
353 warn!(error = ?e, "Failed to elevate to root. Commands will run as normal user.");
354 }
355 }
356
357 Ok(())
358 }
359
360 fn resolve_known_hosts_path(&self) -> Option<PathBuf> {
362 self.config
363 .known_hosts
364 .clone()
365 .or_else(default_known_hosts_path)
366 }
367
368 async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
370 if let Some(ref password) = self.config.password {
372 debug!(
373 "Attempting password authentication for user '{}'",
374 self.config.username
375 );
376 let auth_result = timeout(
377 Duration::from_secs(AUTH_TIMEOUT_SECS),
378 session.authenticate_password(&self.config.username, password),
379 )
380 .await
381 .map_err(|_| {
382 SshMcpError::auth(format!(
383 "Authentication timed out after {}s",
384 AUTH_TIMEOUT_SECS
385 ))
386 })?
387 .map_err(|e| SshMcpError::auth(e.to_string()))?;
388
389 if auth_result.success() {
390 info!("Password authentication successful");
391 return Ok(());
392 } else {
393 return Err(SshMcpError::auth("Password authentication rejected"));
394 }
395 }
396
397 if let Some(ref key_content) = self.config.private_key {
399 debug!(
400 "Attempting key authentication for user '{}'",
401 self.config.username
402 );
403
404 let key = Arc::new(
406 russh::keys::PrivateKey::from_openssh(key_content.as_bytes()).map_err(|e| {
407 SshMcpError::SshKey(format!("Failed to parse private key: {}", e))
408 })?,
409 );
410
411 let hash_attempts: &[Option<HashAlg>] = if key.algorithm().is_rsa() {
414 &[Some(HashAlg::Sha256), Some(HashAlg::Sha512), None]
415 } else {
416 &[None]
417 };
418
419 for hash_alg in hash_attempts {
420 debug!(
421 alg = %key.algorithm(),
422 ?hash_alg,
423 "Attempting publickey authentication"
424 );
425
426 let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&key), *hash_alg);
427
428 let auth_result = timeout(
429 Duration::from_secs(AUTH_TIMEOUT_SECS),
430 session.authenticate_publickey(&self.config.username, key_with_alg),
431 )
432 .await
433 .map_err(|_| {
434 SshMcpError::auth(format!(
435 "Authentication timed out after {}s",
436 AUTH_TIMEOUT_SECS
437 ))
438 })?
439 .map_err(|e| SshMcpError::auth(e.to_string()))?;
440
441 if auth_result.success() {
442 info!("Key authentication successful");
443 return Ok(());
444 }
445 }
446
447 return Err(SshMcpError::auth("Key authentication rejected"));
448 }
449
450 Err(SshMcpError::auth(
451 "No authentication method available (require password or private_key)",
452 ))
453 }
454
455 pub async fn is_connected(&self) -> bool {
457 let session_guard = self.session.lock().await;
458 session_guard.is_some()
459 }
460
461 pub async fn ensure_connected(&self) -> Result<()> {
463 self.ensure_not_shutting_down()?;
464
465 if !self.is_connected().await {
466 return self
467 .connect_with_retry("no active session found during ensure_connected")
468 .await;
469 }
470
471 if self.is_health_probe_fresh().await {
472 return Ok(());
473 }
474
475 let _probe_guard = self.health_probe_lock.lock().await;
476 if self.is_health_probe_fresh().await {
477 return Ok(());
478 }
479
480 if let Err(probe_error) = self.run_health_probe().await {
481 warn!(
482 error = ?probe_error,
483 "SSH health probe failed, invalidating session before reconnect"
484 );
485 self.invalidate_session("health probe failed").await;
486 self.connect_with_retry("health probe failed during ensure_connected")
487 .await?;
488 } else {
489 self.mark_health_probe_ok().await;
490 }
491
492 Ok(())
493 }
494
495 fn health_probe_ttl(&self) -> Duration {
496 let ttl_ms = self
497 .config
498 .health_probe_timeout_ms
499 .saturating_mul(2)
500 .clamp(MIN_HEALTH_PROBE_TTL_MS, MAX_HEALTH_PROBE_TTL_MS);
501 Duration::from_millis(ttl_ms)
502 }
503
504 async fn is_health_probe_fresh(&self) -> bool {
505 let guard = self.last_health_probe_ok_at.lock().await;
506 if let Some(last_ok_at) = guard.as_ref() {
507 return last_ok_at.elapsed() < self.health_probe_ttl();
508 }
509
510 false
511 }
512
513 async fn mark_health_probe_ok(&self) {
514 let mut guard = self.last_health_probe_ok_at.lock().await;
515 *guard = Some(tokio::time::Instant::now());
516 }
517
518 async fn run_health_probe(&self) -> Result<()> {
519 let ping_result = {
520 let session_guard = self.session.lock().await;
521 let session = session_guard
522 .as_ref()
523 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
524
525 timeout(
526 Duration::from_millis(self.config.health_probe_timeout_ms),
527 session.send_ping(),
528 )
529 .await
530 };
531
532 match ping_result {
533 Ok(Ok(())) => Ok(()),
534 Ok(Err(e)) => Err(SshMcpError::connection(format!(
535 "SSH health probe ping failed: {e}"
536 ))),
537 Err(_) => Err(SshMcpError::connection(format!(
538 "SSH health probe timed out after {}ms",
539 self.config.health_probe_timeout_ms
540 ))),
541 }
542 }
543
544 async fn connect_with_retry(&self, reason: &str) -> Result<()> {
545 let max_attempts = self.config.reconnect_retries.saturating_add(1);
546 let mut attempt: u64 = 1;
547 let mut last_error: Option<SshMcpError> = None;
548
549 while attempt <= max_attempts {
550 match self.connect().await {
551 Ok(()) => {
552 if attempt > 1 {
553 info!(
554 attempts = attempt,
555 reason = reason,
556 "SSH reconnect succeeded"
557 );
558 }
559 return Ok(());
560 }
561 Err(err) => {
562 let backoff_ms = self.backoff_for_attempt(attempt);
563 warn!(
564 attempt = attempt,
565 max_attempts = max_attempts,
566 backoff_ms = backoff_ms,
567 reason = reason,
568 error = ?err,
569 "SSH reconnect attempt failed"
570 );
571 last_error = Some(err);
572
573 if attempt < max_attempts && backoff_ms > 0 {
574 sleep(Duration::from_millis(backoff_ms)).await;
575 }
576 }
577 }
578
579 attempt = attempt.saturating_add(1);
580 }
581
582 if let Some(err) = last_error {
583 return Err(err);
584 }
585
586 Err(SshMcpError::connection(
587 "Reconnect retry loop ended without connection result",
588 ))
589 }
590
591 fn backoff_for_attempt(&self, attempt: u64) -> u64 {
592 let exponent = attempt.saturating_sub(1).min(63) as u32;
593 let factor = 1_u64 << exponent;
594 self.config
595 .reconnect_backoff_ms
596 .saturating_mul(factor)
597 .min(MAX_RECONNECT_BACKOFF_MS)
598 }
599
600 pub async fn with_session<F, T>(&self, f: F) -> Result<T>
605 where
606 F: FnOnce(&Handle<SshHandler>) -> T,
607 {
608 self.ensure_not_shutting_down()?;
609 let session_guard = self.session.lock().await;
610 match session_guard.as_ref() {
611 Some(session) => Ok(f(session)),
612 None => Err(SshMcpError::connection("SSH connection not established")),
613 }
614 }
615
616 pub async fn open_channel(&self) -> Result<Channel<client::Msg>> {
618 self.ensure_not_shutting_down()?;
619 let session_guard = self.session.lock().await;
620 let session = session_guard
621 .as_ref()
622 .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
623
624 let channel = session
625 .channel_open_session()
626 .await
627 .map_err(|e| SshMcpError::connection(format!("Failed to open channel: {}", e)))?;
628
629 Ok(channel)
630 }
631
632 pub fn is_elevated(&self) -> bool {
634 self.is_elevated.load(Ordering::SeqCst)
635 }
636
637 pub fn use_timeout_wrapper(&self) -> bool {
642 self.has_timeout_cmd.load(Ordering::SeqCst)
643 }
644
645 pub fn disable_timeout_wrapper(&self) {
651 self.has_timeout_cmd.store(false, Ordering::SeqCst);
652 warn!("timeout wrapper disabled due to errors, falling back to pkill");
653 }
654
655 pub(crate) async fn determine_timeout_wrapper_usage(&self) -> bool {
660 if self.use_timeout_wrapper() {
661 return true;
662 }
663
664 let _ = self.check_timeout_availability().await;
665 self.use_timeout_wrapper()
666 }
667
668 pub async fn check_timeout_availability(&self) -> bool {
677 if self.has_timeout_cmd.load(Ordering::SeqCst) {
679 return true;
680 }
681
682 let mut channel = match self.open_channel().await {
684 Ok(ch) => ch,
685 Err(e) => {
686 debug!(error = ?e, "Failed to open channel for timeout detection");
687 return false;
688 }
689 };
690
691 let exec_result = channel
693 .exec(true, "sh -c 'command -v timeout'")
694 .await
695 .map_err(|e| {
696 SshMcpError::connection(format!("Failed to exec detection command: {}", e))
697 });
698
699 if exec_result.is_err() {
700 debug!("Failed to exec timeout detection command");
701 return false;
702 }
703
704 let mut output = String::new();
706 while let Some(msg) = channel.wait().await {
707 match msg {
708 ChannelMsg::Data { data } => {
709 output.push_str(&String::from_utf8_lossy(&data));
710 }
711 ChannelMsg::Close | ChannelMsg::Eof => {
712 break;
713 }
714 _ => {
715 }
717 }
718 }
719
720 let available = !output.is_empty();
722 self.has_timeout_cmd.store(available, Ordering::SeqCst);
723
724 if available {
725 info!("timeout command available on remote host");
726 } else {
727 info!("timeout command NOT available, using fallback pkill");
728 }
729
730 available
731 }
732
733 pub async fn has_su_channel(&self) -> bool {
735 let channel_guard = self.su_channel.lock().await;
736 channel_guard.is_some()
737 }
738
739 pub async fn with_su_channel<F, Fut, T>(&self, f: F) -> Result<T>
744 where
745 F: FnOnce(&mut Option<Channel<client::Msg>>) -> Fut,
746 Fut: std::future::Future<Output = Result<T>>,
747 {
748 self.ensure_not_shutting_down()?;
749 let mut channel_guard = self.su_channel.lock().await;
750 f(&mut channel_guard).await
751 }
752
753 pub async fn ensure_elevated(&self) -> Result<()> {
758 self.ensure_not_shutting_down()?;
759
760 if self.is_elevated.load(Ordering::SeqCst) {
762 let channel_guard = self.su_channel.lock().await;
763 if channel_guard.is_some() {
764 return Ok(());
765 }
766 }
767
768 let su_password = self
770 .config
771 .su_password
772 .clone()
773 .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
774
775 let channel = self
777 .open_channel()
778 .await
779 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to open channel: {}", e)))?;
780
781 debug!("Opened channel for su elevation");
782
783 channel
785 .request_pty(
786 true, "xterm",
788 80, 24, 0, 0, &[], )
794 .await
795 .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
796
797 debug!("PTY requested");
798
799 channel.request_shell(true).await.map_err(|e| {
801 SshMcpError::elevation_failed(format!("Failed to request shell: {}", e))
802 })?;
803
804 debug!("Shell requested, starting su elevation...");
805
806 channel.data(b"su -\n".as_slice()).await.map_err(|e| {
808 SshMcpError::elevation_failed(format!("Failed to send su command: {}", e))
809 })?;
810
811 let elevation_result = self.handle_su_elevation(channel, &su_password).await;
813
814 match elevation_result {
815 Ok(elevated_channel) => {
816 let mut channel_guard = self.su_channel.lock().await;
818 if self.is_shutting_down() {
819 drop(channel_guard);
820 let _ = elevated_channel.eof().await;
821 return self.ensure_not_shutting_down();
822 }
823 *channel_guard = Some(elevated_channel);
824 self.is_elevated.store(true, Ordering::SeqCst);
825 info!("Successfully elevated to root via su");
826 Ok(())
827 }
828 Err(e) => {
829 self.is_elevated.store(false, Ordering::SeqCst);
830 Err(e)
831 }
832 }
833 }
834
835 async fn handle_su_elevation(
837 &self,
838 mut channel: Channel<client::Msg>,
839 password: &str,
840 ) -> Result<Channel<client::Msg>> {
841 use russh::ChannelMsg;
842
843 let elevation_timeout = Duration::from_secs(10);
844 let mut buffer = String::new();
845 let mut password_sent = false;
846
847 let deadline = tokio::time::Instant::now() + elevation_timeout;
848
849 loop {
850 if tokio::time::Instant::now() > deadline {
852 return Err(SshMcpError::elevation_failed("su elevation timed out"));
853 }
854
855 let wait_result =
857 tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
858
859 match wait_result {
860 Ok(Some(msg)) => {
861 match msg {
862 ChannelMsg::Data { data } => {
863 let text = String::from_utf8_lossy(&data);
864 buffer.push_str(&text);
865 debug!(su_buffer_len = buffer.len(), "su buffer received");
866
867 if !password_sent && buffer.to_lowercase().contains("password") {
869 debug!("Password prompt detected, sending password...");
870 channel
871 .data(format!("{}\n", password).as_bytes())
872 .await
873 .map_err(|e| {
874 SshMcpError::elevation_failed(format!(
875 "Failed to send password: {}",
876 e
877 ))
878 })?;
879 password_sent = true;
880 buffer.clear();
882 }
883
884 if password_sent && buffer.contains('#') {
886 debug!("Root prompt detected, elevation successful");
887 return Ok(channel);
888 }
889
890 if buffer.to_lowercase().contains("authentication failure")
892 || buffer.to_lowercase().contains("incorrect password")
893 || buffer.to_lowercase().contains("su: failed")
894 || buffer.to_lowercase().contains("su: authentication")
895 {
896 return Err(SshMcpError::elevation_failed(format!(
897 "su authentication failed: {}",
898 buffer
899 )));
900 }
901 }
902 ChannelMsg::Close => {
903 return Err(SshMcpError::elevation_failed(
904 "Channel closed before elevation completed",
905 ));
906 }
907 _ => {
908 }
910 }
911 }
912 Ok(None) => {
913 return Err(SshMcpError::elevation_failed(
915 "Channel ended before elevation completed",
916 ));
917 }
918 Err(_) => {
919 continue;
921 }
922 }
923 }
924 }
925
926 pub fn get_su_password(&self) -> Option<&str> {
928 self.config.su_password.as_deref()
929 }
930
931 pub fn get_sudo_password(&self) -> Option<&str> {
933 self.config.sudo_password.as_deref()
934 }
935
936 pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
941 if password.is_some() {
947 self.ensure_elevated().await?;
950 } else {
951 let mut channel_guard = self.su_channel.lock().await;
953 if let Some(ch) = channel_guard.take() {
954 let _ = ch.eof().await;
956 }
957 self.is_elevated.store(false, Ordering::SeqCst);
958 }
959
960 Ok(())
961 }
962
963 pub async fn close(&self) {
965 self.shutting_down.store(true, Ordering::SeqCst);
966
967 let su_channel = {
969 let mut channel_guard = self.su_channel.lock().await;
970 channel_guard.take()
971 };
972 if let Some(ch) = su_channel {
973 let _ = ch.eof().await;
974 }
975 self.is_elevated.store(false, Ordering::SeqCst);
976
977 let session = {
979 let mut session_guard = self.session.lock().await;
980 session_guard.take()
981 };
982 if let Some(session) = session {
983 let _ = session
984 .disconnect(russh::Disconnect::ByApplication, "", "")
985 .await;
986 }
987
988 {
989 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
990 *probe_guard = None;
991 }
992
993 info!("SSH connection closed");
994 }
995
996 pub async fn invalidate_session(&self, reason: &str) {
1001 warn!(reason = ?reason, "Invalidating SSH session");
1002
1003 let channel = {
1005 let mut channel_guard = self.su_channel.lock().await;
1006 channel_guard.take()
1007 };
1008
1009 if let Some(ch) = channel {
1011 let _ = ch.eof().await;
1012 }
1013 self.is_elevated.store(false, Ordering::SeqCst);
1014
1015 let session = {
1017 let mut session_guard = self.session.lock().await;
1018 session_guard.take()
1019 };
1020
1021 if let Some(session) = session {
1022 let _ = tokio::time::timeout(
1023 Duration::from_millis(500),
1024 session.disconnect(russh::Disconnect::ByApplication, "", ""),
1025 )
1026 .await;
1027 }
1028
1029 {
1030 let mut probe_guard = self.last_health_probe_ok_at.lock().await;
1031 *probe_guard = None;
1032 }
1033
1034 debug!(reason = ?reason, "Session invalidated");
1035 }
1036
1037 pub async fn reconnect(&self) -> Result<()> {
1043 self.ensure_not_shutting_down()?;
1044 self.invalidate_session("explicit reconnect requested")
1045 .await;
1046 self.connect_with_retry("explicit reconnect requested")
1047 .await
1048 }
1049}
1050
1051impl std::fmt::Debug for SshConnectionManager {
1052 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1053 f.debug_struct("SshConnectionManager")
1054 .field("host", &self.config.host)
1055 .field("port", &self.config.port)
1056 .field("username", &self.config.username)
1057 .field("is_connecting", &self.is_connecting.load(Ordering::SeqCst))
1058 .field("shutting_down", &self.shutting_down.load(Ordering::SeqCst))
1059 .field("is_elevated", &self.is_elevated.load(Ordering::SeqCst))
1060 .field(
1061 "has_timeout_cmd",
1062 &self.has_timeout_cmd.load(Ordering::SeqCst),
1063 )
1064 .finish()
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 #[tokio::test]
1073 async fn test_connection_manager_creation() {
1074 let config = SshConfig::new("localhost", "testuser")
1075 .with_port(22)
1076 .with_password("testpass");
1077
1078 let manager = SshConnectionManager::new(config).await;
1079
1080 assert!(!manager.is_connected().await);
1081 assert!(!manager.is_elevated());
1082 }
1083
1084 #[tokio::test]
1085 async fn test_not_connected_initially() {
1086 let config = SshConfig::new("localhost", "testuser");
1087 let manager = SshConnectionManager::new(config).await;
1088
1089 let result = manager.open_channel().await;
1091 assert!(result.is_err());
1092 }
1093
1094 #[tokio::test]
1095 async fn test_close_prevents_new_ssh_work() {
1096 let config = SshConfig::new("127.0.0.1", "testuser").with_port(9);
1097 let manager = SshConnectionManager::new(config).await;
1098
1099 manager.close().await;
1100 manager.close().await;
1101
1102 assert!(manager.is_shutting_down());
1103 assert!(manager.connect().await.is_err());
1104 assert!(manager.ensure_connected().await.is_err());
1105 assert!(manager.open_channel().await.is_err());
1106 assert!(manager.reconnect().await.is_err());
1107 }
1108
1109 #[tokio::test]
1110 async fn test_cancelled_connect_releases_owner_flag() {
1111 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1112 .await
1113 .expect("bind test listener");
1114 let port = listener.local_addr().expect("test listener address").port();
1115 let accept_task = tokio::spawn(async move {
1116 let (_stream, _) = listener.accept().await.expect("accept test connection");
1117 std::future::pending::<()>().await;
1118 });
1119 let config = SshConfig::new("127.0.0.1", "testuser")
1120 .with_port(port)
1121 .with_password("testpass");
1122 let manager = SshConnectionManager::new(config).await;
1123
1124 let result = timeout(Duration::from_millis(100), manager.connect()).await;
1125 assert!(result.is_err(), "silent peer should keep connect in flight");
1126 assert!(
1127 !manager.is_connecting.load(Ordering::SeqCst),
1128 "cancelling connect must release the owner flag"
1129 );
1130
1131 accept_task.abort();
1132 }
1133}