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
34/// SSH Connection Manager
35///
36/// Manages a persistent SSH connection with the following features:
37/// - Automatic reconnection when connection drops
38/// - Concurrent access protection via mutex/atomic flags
39/// - Optional `su` elevation for privileged operations
40/// - 30-second connection timeout
41pub struct SshConnectionManager {
42    /// SSH configuration
43    /// Made pub(crate) to allow access from command.rs for output limiting
44    pub(crate) config: SshConfig,
45
46    /// Active SSH session handle
47    session: Arc<Mutex<Option<Handle<SshHandler>>>>,
48
49    /// Flag to prevent concurrent connection attempts
50    is_connecting: AtomicBool,
51
52    /// Notification for waiters when connection attempt completes
53    connect_notify: Arc<Notify>,
54
55    /// Elevated shell channel (when using su)
56    /// Made pub(crate) to allow access from command.rs
57    pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
58
59    /// Flag indicating whether we're running as root via su
60    /// Made pub(crate) to allow access from command.rs for su state reset
61    pub(crate) is_elevated: AtomicBool,
62
63    /// Cached availability of the `timeout` command on the remote system
64    has_timeout_cmd: AtomicBool,
65
66    /// Semaphore to limit concurrent command execution
67    /// Made pub(crate) to allow access from command.rs
68    pub(crate) channel_semaphore: Arc<Semaphore>,
69
70    /// Last successful active health probe timestamp
71    last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
72
73    /// Lock to avoid concurrent active health probes
74    health_probe_lock: Arc<Mutex<()>>,
75}
76
77impl SshConnectionManager {
78    /// Create a new SSH Connection Manager
79    ///
80    /// Does not establish connection immediately; call `connect()` or
81    /// `ensure_connected()` to establish the connection.
82    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    /// Establish SSH connection
110    ///
111    /// If already connected, returns immediately. If another task is currently
112    /// connecting, waits for that connection attempt to complete.
113    pub async fn connect(&self) -> Result<()> {
114        // Check if already connected
115        if self.is_connected().await {
116            debug!("Already connected to SSH server");
117            return Ok(());
118        }
119
120        // Prevent concurrent connection attempts
121        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            // Wait for the other connection attempt to complete using Notify
128            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        // Perform connection with timeout
151        let result = self.do_connect().await;
152
153        // Reset connecting flag and notify all waiters
154        self.is_connecting.store(false, Ordering::SeqCst);
155        self.connect_notify.notify_waiters();
156
157        result
158    }
159
160    /// Internal connection logic
161    ///
162    /// On a changed host key in `accept-new` mode, removes the stale
163    /// known_hosts entry and retries once.  All other failures are
164    /// returned immediately.
165    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        // First attempt — record key check outcome for recovery decisions.
182        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                // If the failure was a changed host key in accept-new mode,
198                // remove the stale entry and retry once.
199                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                    // Single retry — fresh handler, no outcome recording.
228                    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    /// Attempt a single SSH connection (no retry logic).
263    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    /// Authenticate, store the session, and optionally elevate.
286    async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
287        // Authenticate
288        self.authenticate(&mut session).await?;
289
290        // Store session
291        {
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 su_password is configured, attempt elevation
306        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    /// Resolve the known_hosts file path — explicit config or default.
317    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    /// Authenticate with the SSH server
325    async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
326        // Try password authentication first
327        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        // Try key authentication
354        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            // Parse the private key using russh::keys
361            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            // For RSA, try modern rsa-sha2-256/512 first, then legacy ssh-rsa (SHA-1) as fallback.
368            // For non-RSA keys, the hash algorithm is ignored by russh.
369            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    /// Check if the connection is active
412    pub async fn is_connected(&self) -> bool {
413        let session_guard = self.session.lock().await;
414        session_guard.is_some()
415    }
416
417    /// Ensure connection is established, reconnecting if necessary
418    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    /// Get a reference to the session for operations
555    ///
556    /// Instead of cloning the Handle (which doesn't implement Clone),
557    /// we provide methods that work with the session directly.
558    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    /// Open a new session channel
570    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    /// Check if currently elevated to root via su
585    pub fn is_elevated(&self) -> bool {
586        self.is_elevated.load(Ordering::SeqCst)
587    }
588
589    /// Check if the `timeout` command is available on the remote system
590    ///
591    /// Uses cached result after first check. To trigger a new check,
592    /// the connection must be re-established.
593    pub fn use_timeout_wrapper(&self) -> bool {
594        self.has_timeout_cmd.load(Ordering::SeqCst)
595    }
596
597    /// Disables timeout wrapper for the rest of this connection lifetime
598    ///
599    /// When called, this sets `has_timeout_cmd` to false, causing all subsequent
600    /// commands to fall back to the tokio timeout + pkill method instead of using
601    /// the remote timeout command wrapper.
602    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    /// Lazily check and return whether to use the remote timeout wrapper
608    ///
609    /// This performs a one-time remote detection on first need and then returns
610    /// the cached decision for the lifetime of the connection.
611    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    /// Detect whether the `timeout` command is available on the remote system
621    ///
622    /// This performs a one-time detection check by running
623    /// `sh -c 'command -v timeout'`
624    /// on the remote system. The result is cached for the lifetime of the
625    /// connection.
626    ///
627    /// Returns true if timeout is available, false otherwise.
628    pub async fn check_timeout_availability(&self) -> bool {
629        // Check cache first
630        if self.has_timeout_cmd.load(Ordering::SeqCst) {
631            return true;
632        }
633
634        // Open a new channel for detection
635        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        // Run detection command
644        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        // Collect output
657        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                    // Ignore other messages
668                }
669            }
670        }
671
672        // If timeout command exists, output contains its path (e.g., /usr/bin/timeout)
673        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    /// Check if an elevated su channel is available
686    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    /// Execute a closure with access to the su channel
692    ///
693    /// The closure receives a mutable reference to the Option<Channel>,
694    /// allowing it to use the channel for operations.
695    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    /// Ensure we have an elevated shell via `su`
705    ///
706    /// This starts an interactive PTY session, runs `su -`, sends the password,
707    /// and waits for the root prompt (#).
708    pub async fn ensure_elevated(&self) -> Result<()> {
709        // Already elevated?
710        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        // Need su_password
718        let su_password = self
719            .config
720            .su_password
721            .clone()
722            .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
723
724        // Open a channel for PTY shell
725        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        // Request PTY
733        channel
734            .request_pty(
735                true, // want_reply
736                "xterm",
737                80,  // cols
738                24,  // rows
739                0,   // pixel width
740                0,   // pixel height
741                &[], // terminal modes
742            )
743            .await
744            .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
745
746        debug!("PTY requested");
747
748        // Request shell
749        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        // Send "su -\n" command
756        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        // Wait for password prompt and respond
761        let elevation_result = self.handle_su_elevation(channel, &su_password).await;
762
763        match elevation_result {
764            Ok(elevated_channel) => {
765                // Store the elevated channel
766                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    /// Handle the interactive su elevation process
780    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            // Check timeout
795            if tokio::time::Instant::now() > deadline {
796                return Err(SshMcpError::elevation_failed("su elevation timed out"));
797            }
798
799            // Wait for messages with timeout
800            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                            // Check for password prompt
812                            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                                // Clear buffer to avoid re-matching password prompt
825                                buffer.clear();
826                            }
827
828                            // Check for root prompt after password sent
829                            if password_sent && buffer.contains('#') {
830                                debug!("Root prompt detected, elevation successful");
831                                return Ok(channel);
832                            }
833
834                            // Check for authentication failure
835                            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                            // Ignore other messages
853                        }
854                    }
855                }
856                Ok(None) => {
857                    // Channel ended
858                    return Err(SshMcpError::elevation_failed(
859                        "Channel ended before elevation completed",
860                    ));
861                }
862                Err(_) => {
863                    // Timeout on wait, continue loop
864                    continue;
865                }
866            }
867        }
868    }
869
870    /// Get the su password if configured
871    pub fn get_su_password(&self) -> Option<&str> {
872        self.config.su_password.as_deref()
873    }
874
875    /// Get the sudo password if configured
876    pub fn get_sudo_password(&self) -> Option<&str> {
877        self.config.sudo_password.as_deref()
878    }
879
880    /// Set or update the su password
881    ///
882    /// If setting a new password, will attempt to establish elevation.
883    /// If clearing the password (None), will close any existing su shell.
884    pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
885        // Note: We can't modify self.config directly since we only have &self
886        // In the TypeScript version, this modifies the config and triggers elevation.
887        // For Rust, we'd need interior mutability. For now, just attempt elevation
888        // if password is provided.
889
890        if password.is_some() {
891            // Attempt elevation with the current config
892            // In a real implementation, we'd need to update config first
893            self.ensure_elevated().await?;
894        } else {
895            // Clear elevation state
896            let mut channel_guard = self.su_channel.lock().await;
897            if let Some(ch) = channel_guard.take() {
898                // Try to close the channel gracefully
899                let _ = ch.eof().await;
900            }
901            self.is_elevated.store(false, Ordering::SeqCst);
902        }
903
904        Ok(())
905    }
906
907    /// Close the SSH connection
908    pub async fn close(&self) {
909        // Close su channel if exists
910        {
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        // Close main session
919        {
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    /// Invalidate the current session and clear elevation state
937    ///
938    /// This clears the session handle, su_channel, and resets elevation state.
939    /// Used when a connection is detected as broken and needs reconnection.
940    pub async fn invalidate_session(&self, reason: &str) {
941        warn!(reason = ?reason, "Invalidating SSH session");
942
943        // Take channel out of mutex before awaiting to avoid deadlock
944        let channel = {
945            let mut channel_guard = self.su_channel.lock().await;
946            channel_guard.take()
947        };
948
949        // Drop lock before awaiting EOF
950        if let Some(ch) = channel {
951            let _ = ch.eof().await;
952        }
953        self.is_elevated.store(false, Ordering::SeqCst);
954
955        // Attempt best-effort graceful disconnect with short timeout.
956        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    /// Force a reconnection by invalidating the current session and reconnecting
978    ///
979    /// This is used when the connection is known to be broken and a fresh
980    /// connection is required. It clears all session state and performs
981    /// a new connection attempt.
982    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        // Should return error when trying to open channel without connecting
1028        let result = manager.open_channel().await;
1029        assert!(result.is_err());
1030    }
1031}