Skip to main content

ssh_mcp/ssh/
connection.rs

1//! SSH Connection Manager
2//!
3//! Provides persistent SSH connection handling with automatic reconnection,
4//! concurrent access protection, and optional privilege elevation via `su`.
5
6use 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
26/// Default capacity for the channel semaphore (max concurrent commands)
27pub 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
46/// SSH Connection Manager
47///
48/// Manages a persistent SSH connection with the following features:
49/// - Automatic reconnection when connection drops
50/// - Concurrent access protection via mutex/atomic flags
51/// - Optional `su` elevation for privileged operations
52/// - 30-second connection timeout
53pub struct SshConnectionManager {
54    /// SSH configuration
55    /// Made pub(crate) to allow access from command.rs for output limiting
56    pub(crate) config: SshConfig,
57
58    /// Active SSH session handle
59    session: Arc<Mutex<Option<Handle<SshHandler>>>>,
60
61    /// Flag to prevent concurrent connection attempts
62    is_connecting: AtomicBool,
63
64    /// Terminal gate preventing new SSH work after shutdown begins.
65    shutting_down: AtomicBool,
66
67    /// Notification for waiters when connection attempt completes
68    connect_notify: Arc<Notify>,
69
70    /// Elevated shell channel (when using su)
71    /// Made pub(crate) to allow access from command.rs
72    pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
73
74    /// Flag indicating whether we're running as root via su
75    /// Made pub(crate) to allow access from command.rs for su state reset
76    pub(crate) is_elevated: AtomicBool,
77
78    /// Cached availability of the `timeout` command on the remote system
79    has_timeout_cmd: AtomicBool,
80
81    /// Semaphore to limit concurrent command execution
82    /// Made pub(crate) to allow access from command.rs
83    pub(crate) channel_semaphore: Arc<Semaphore>,
84
85    /// Last successful active health probe timestamp
86    last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
87
88    /// Lock to avoid concurrent active health probes
89    health_probe_lock: Arc<Mutex<()>>,
90}
91
92impl SshConnectionManager {
93    /// Create a new SSH Connection Manager
94    ///
95    /// Does not establish connection immediately; call `connect()` or
96    /// `ensure_connected()` to establish the connection.
97    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    /// Establish SSH connection
140    ///
141    /// If already connected, returns immediately. If another task is currently
142    /// connecting, waits for that connection attempt to complete.
143    pub async fn connect(&self) -> Result<()> {
144        self.ensure_not_shutting_down()?;
145
146        // Check if already connected
147        if self.is_connected().await {
148            debug!("Already connected to SSH server");
149            return Ok(());
150        }
151
152        // Register before inspecting the flag so completion cannot race the waiter.
153        let notified = self.connect_notify.notified();
154        tokio::pin!(notified);
155        notified.as_mut().enable();
156
157        // Prevent concurrent connection attempts
158        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            // Wait for the other connection attempt to complete using Notify
165            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        // Cancellation must release the owner flag and wake waiters.
187        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    /// Internal connection logic
196    ///
197    /// On a changed host key in `accept-new` mode, removes the stale
198    /// known_hosts entry and retries once.  All other failures are
199    /// returned immediately.
200    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        // First attempt — record key check outcome for recovery decisions.
217        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                // If the failure was a changed host key in accept-new mode,
233                // remove the stale entry and retry once.
234                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                    // Single retry — fresh handler, no outcome recording.
263                    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    /// Attempt a single SSH connection (no retry logic).
298    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    /// Authenticate, store the session, and optionally elevate.
321    async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
322        // Authenticate
323        self.authenticate(&mut session).await?;
324
325        // Do not publish a connection that completed after shutdown began.
326        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 su_password is configured, attempt elevation
350        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    /// Resolve the known_hosts file path — explicit config or default.
361    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    /// Authenticate with the SSH server
369    async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
370        // Try password authentication first
371        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        // Try key authentication
398        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            // Parse the private key using russh::keys
405            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            // For RSA, try modern rsa-sha2-256/512 first, then legacy ssh-rsa (SHA-1) as fallback.
412            // For non-RSA keys, the hash algorithm is ignored by russh.
413            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    /// Check if the connection is active
456    pub async fn is_connected(&self) -> bool {
457        let session_guard = self.session.lock().await;
458        session_guard.is_some()
459    }
460
461    /// Ensure connection is established, reconnecting if necessary
462    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    /// Get a reference to the session for operations
601    ///
602    /// Instead of cloning the Handle (which doesn't implement Clone),
603    /// we provide methods that work with the session directly.
604    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    /// Open a new session channel
617    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    /// Check if currently elevated to root via su
633    pub fn is_elevated(&self) -> bool {
634        self.is_elevated.load(Ordering::SeqCst)
635    }
636
637    /// Check if the `timeout` command is available on the remote system
638    ///
639    /// Uses cached result after first check. To trigger a new check,
640    /// the connection must be re-established.
641    pub fn use_timeout_wrapper(&self) -> bool {
642        self.has_timeout_cmd.load(Ordering::SeqCst)
643    }
644
645    /// Disables timeout wrapper for the rest of this connection lifetime
646    ///
647    /// When called, this sets `has_timeout_cmd` to false, causing all subsequent
648    /// commands to fall back to the tokio timeout + pkill method instead of using
649    /// the remote timeout command wrapper.
650    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    /// Lazily check and return whether to use the remote timeout wrapper
656    ///
657    /// This performs a one-time remote detection on first need and then returns
658    /// the cached decision for the lifetime of the connection.
659    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    /// Detect whether the `timeout` command is available on the remote system
669    ///
670    /// This performs a one-time detection check by running
671    /// `sh -c 'command -v timeout'`
672    /// on the remote system. The result is cached for the lifetime of the
673    /// connection.
674    ///
675    /// Returns true if timeout is available, false otherwise.
676    pub async fn check_timeout_availability(&self) -> bool {
677        // Check cache first
678        if self.has_timeout_cmd.load(Ordering::SeqCst) {
679            return true;
680        }
681
682        // Open a new channel for detection
683        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        // Run detection command
692        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        // Collect output
705        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                    // Ignore other messages
716                }
717            }
718        }
719
720        // If timeout command exists, output contains its path (e.g., /usr/bin/timeout)
721        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    /// Check if an elevated su channel is available
734    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    /// Execute a closure with access to the su channel
740    ///
741    /// The closure receives a mutable reference to the Option<Channel>,
742    /// allowing it to use the channel for operations.
743    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    /// Ensure we have an elevated shell via `su`
754    ///
755    /// This starts an interactive PTY session, runs `su -`, sends the password,
756    /// and waits for the root prompt (#).
757    pub async fn ensure_elevated(&self) -> Result<()> {
758        self.ensure_not_shutting_down()?;
759
760        // Already elevated?
761        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        // Need su_password
769        let su_password = self
770            .config
771            .su_password
772            .clone()
773            .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
774
775        // Open a channel for PTY shell
776        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        // Request PTY
784        channel
785            .request_pty(
786                true, // want_reply
787                "xterm",
788                80,  // cols
789                24,  // rows
790                0,   // pixel width
791                0,   // pixel height
792                &[], // terminal modes
793            )
794            .await
795            .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
796
797        debug!("PTY requested");
798
799        // Request shell
800        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        // Send "su -\n" command
807        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        // Wait for password prompt and respond
812        let elevation_result = self.handle_su_elevation(channel, &su_password).await;
813
814        match elevation_result {
815            Ok(elevated_channel) => {
816                // Store the elevated channel
817                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    /// Handle the interactive su elevation process
836    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            // Check timeout
851            if tokio::time::Instant::now() > deadline {
852                return Err(SshMcpError::elevation_failed("su elevation timed out"));
853            }
854
855            // Wait for messages with timeout
856            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                            // Check for password prompt
868                            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                                // Clear buffer to avoid re-matching password prompt
881                                buffer.clear();
882                            }
883
884                            // Check for root prompt after password sent
885                            if password_sent && buffer.contains('#') {
886                                debug!("Root prompt detected, elevation successful");
887                                return Ok(channel);
888                            }
889
890                            // Check for authentication failure
891                            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                            // Ignore other messages
909                        }
910                    }
911                }
912                Ok(None) => {
913                    // Channel ended
914                    return Err(SshMcpError::elevation_failed(
915                        "Channel ended before elevation completed",
916                    ));
917                }
918                Err(_) => {
919                    // Timeout on wait, continue loop
920                    continue;
921                }
922            }
923        }
924    }
925
926    /// Get the su password if configured
927    pub fn get_su_password(&self) -> Option<&str> {
928        self.config.su_password.as_deref()
929    }
930
931    /// Get the sudo password if configured
932    pub fn get_sudo_password(&self) -> Option<&str> {
933        self.config.sudo_password.as_deref()
934    }
935
936    /// Set or update the su password
937    ///
938    /// If setting a new password, will attempt to establish elevation.
939    /// If clearing the password (None), will close any existing su shell.
940    pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
941        // Note: We can't modify self.config directly since we only have &self
942        // In the TypeScript version, this modifies the config and triggers elevation.
943        // For Rust, we'd need interior mutability. For now, just attempt elevation
944        // if password is provided.
945
946        if password.is_some() {
947            // Attempt elevation with the current config
948            // In a real implementation, we'd need to update config first
949            self.ensure_elevated().await?;
950        } else {
951            // Clear elevation state
952            let mut channel_guard = self.su_channel.lock().await;
953            if let Some(ch) = channel_guard.take() {
954                // Try to close the channel gracefully
955                let _ = ch.eof().await;
956            }
957            self.is_elevated.store(false, Ordering::SeqCst);
958        }
959
960        Ok(())
961    }
962
963    /// Close the SSH connection
964    pub async fn close(&self) {
965        self.shutting_down.store(true, Ordering::SeqCst);
966
967        // Close su channel if exists
968        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        // Close main session
978        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    /// Invalidate the current session and clear elevation state
997    ///
998    /// This clears the session handle, su_channel, and resets elevation state.
999    /// Used when a connection is detected as broken and needs reconnection.
1000    pub async fn invalidate_session(&self, reason: &str) {
1001        warn!(reason = ?reason, "Invalidating SSH session");
1002
1003        // Take channel out of mutex before awaiting to avoid deadlock
1004        let channel = {
1005            let mut channel_guard = self.su_channel.lock().await;
1006            channel_guard.take()
1007        };
1008
1009        // Drop lock before awaiting EOF
1010        if let Some(ch) = channel {
1011            let _ = ch.eof().await;
1012        }
1013        self.is_elevated.store(false, Ordering::SeqCst);
1014
1015        // Attempt best-effort graceful disconnect with short timeout.
1016        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    /// Force a reconnection by invalidating the current session and reconnecting
1038    ///
1039    /// This is used when the connection is known to be broken and a fresh
1040    /// connection is required. It clears all session state and performs
1041    /// a new connection attempt.
1042    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        // Should return error when trying to open channel without connecting
1090        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}