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    /// Notification for waiters when connection attempt completes
65    connect_notify: Arc<Notify>,
66
67    /// Elevated shell channel (when using su)
68    /// Made pub(crate) to allow access from command.rs
69    pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
70
71    /// Flag indicating whether we're running as root via su
72    /// Made pub(crate) to allow access from command.rs for su state reset
73    pub(crate) is_elevated: AtomicBool,
74
75    /// Cached availability of the `timeout` command on the remote system
76    has_timeout_cmd: AtomicBool,
77
78    /// Semaphore to limit concurrent command execution
79    /// Made pub(crate) to allow access from command.rs
80    pub(crate) channel_semaphore: Arc<Semaphore>,
81
82    /// Last successful active health probe timestamp
83    last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
84
85    /// Lock to avoid concurrent active health probes
86    health_probe_lock: Arc<Mutex<()>>,
87}
88
89impl SshConnectionManager {
90    /// Create a new SSH Connection Manager
91    ///
92    /// Does not establish connection immediately; call `connect()` or
93    /// `ensure_connected()` to establish the connection.
94    pub async fn new(config: SshConfig) -> Self {
95        Self {
96            config,
97            session: Arc::new(Mutex::new(None)),
98            is_connecting: AtomicBool::new(false),
99            connect_notify: Arc::new(Notify::new()),
100            su_channel: Arc::new(Mutex::new(None)),
101            is_elevated: AtomicBool::new(false),
102            has_timeout_cmd: AtomicBool::new(false),
103            channel_semaphore: Arc::new(Semaphore::new(CHANNEL_SEMAPHORE_CAPACITY)),
104            last_health_probe_ok_at: Arc::new(Mutex::new(None)),
105            health_probe_lock: Arc::new(Mutex::new(())),
106        }
107    }
108
109    pub(crate) async fn acquire_command_slot_raw(
110        &self,
111    ) -> std::result::Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
112        self.channel_semaphore.clone().acquire_owned().await
113    }
114
115    pub(crate) async fn acquire_command_slot(&self) -> Result<OwnedSemaphorePermit> {
116        self.acquire_command_slot_raw()
117            .await
118            .map_err(|e| SshMcpError::connection(format!("Failed to acquire command slot: {e}")))
119    }
120
121    /// Establish SSH connection
122    ///
123    /// If already connected, returns immediately. If another task is currently
124    /// connecting, waits for that connection attempt to complete.
125    pub async fn connect(&self) -> Result<()> {
126        // Check if already connected
127        if self.is_connected().await {
128            debug!("Already connected to SSH server");
129            return Ok(());
130        }
131
132        // Register before inspecting the flag so completion cannot race the waiter.
133        let notified = self.connect_notify.notified();
134        tokio::pin!(notified);
135        notified.as_mut().enable();
136
137        // Prevent concurrent connection attempts
138        if self
139            .is_connecting
140            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
141            .is_err()
142        {
143            debug!("Another connection attempt in progress, waiting...");
144            // Wait for the other connection attempt to complete using Notify
145            let wait_result =
146                timeout(Duration::from_secs(CONNECT_WAIT_TIMEOUT_SECS), notified).await;
147            if wait_result.is_err() {
148                warn!(
149                    "Timed out waiting for in-flight connection attempt after {}s",
150                    CONNECT_WAIT_TIMEOUT_SECS
151                );
152                return Err(SshMcpError::connection(format!(
153                    "Timed out waiting for in-flight connection attempt after {}s",
154                    CONNECT_WAIT_TIMEOUT_SECS
155                )));
156            }
157            return if self.is_connected().await {
158                Ok(())
159            } else {
160                Err(SshMcpError::connection("Connection failed by another task"))
161            };
162        }
163
164        // Cancellation must release the owner flag and wake waiters.
165        let _attempt_guard = ConnectAttemptGuard {
166            is_connecting: &self.is_connecting,
167            connect_notify: self.connect_notify.as_ref(),
168        };
169
170        self.do_connect().await
171    }
172
173    /// Internal connection logic
174    ///
175    /// On a changed host key in `accept-new` mode, removes the stale
176    /// known_hosts entry and retries once.  All other failures are
177    /// returned immediately.
178    async fn do_connect(&self) -> Result<()> {
179        info!(
180            "Connecting to SSH server {}:{}...",
181            self.config.host, self.config.port
182        );
183
184        let connection_timeout = Duration::from_secs(CONNECTION_TIMEOUT_SECS);
185
186        let ssh_config = Arc::new(client::Config {
187            keepalive_interval: Some(Duration::from_secs(self.config.keepalive_interval)),
188            keepalive_max: self.config.keepalive_max as usize,
189            ..Default::default()
190        });
191
192        let addr = format!("{}:{}", self.config.host, self.config.port);
193
194        // First attempt — record key check outcome for recovery decisions.
195        let key_outcome = Arc::new(std::sync::Mutex::new(None::<KeyCheckOutcome>));
196        let handler = SshHandler::new(
197            self.config.host.clone(),
198            self.config.port,
199            self.config.host_key_checking,
200            self.config.known_hosts.clone(),
201        )
202        .with_key_check_outcome(key_outcome.clone());
203
204        match self
205            .attempt_connect(&ssh_config, &addr, handler, connection_timeout)
206            .await
207        {
208            Ok(session) => self.finish_connect(session).await,
209            Err(first_err) => {
210                // If the failure was a changed host key in accept-new mode,
211                // remove the stale entry and retry once.
212                let outcome = key_outcome.lock().unwrap().take();
213                if matches!(outcome, Some(KeyCheckOutcome::KeyChanged))
214                    && self.config.host_key_checking == HostKeyCheckMode::AcceptNew
215                {
216                    let Some(path) = self.resolve_known_hosts_path() else {
217                        error!(
218                            host = %self.config.host,
219                            port = self.config.port,
220                            "Host key changed but cannot resolve known_hosts path for recovery"
221                        );
222                        return Err(first_err);
223                    };
224
225                    warn!(
226                        host = %self.config.host,
227                        port = self.config.port,
228                        path = %path.display(),
229                        "Host key changed in accept-new mode; \
230                         removing stale known_hosts entry and retrying once"
231                    );
232                    remove_known_hosts_entry(&self.config.host, self.config.port, &path).map_err(
233                        |e| {
234                            SshMcpError::connection(format!(
235                                "Failed to remove stale known_hosts entry: {e}"
236                            ))
237                        },
238                    )?;
239
240                    // Single retry — fresh handler, no outcome recording.
241                    let retry_handler = SshHandler::new(
242                        self.config.host.clone(),
243                        self.config.port,
244                        self.config.host_key_checking,
245                        self.config.known_hosts.clone(),
246                    );
247                    match self
248                        .attempt_connect(&ssh_config, &addr, retry_handler, connection_timeout)
249                        .await
250                    {
251                        Ok(session) => {
252                            info!(
253                                host = %self.config.host,
254                                port = self.config.port,
255                                "SSH reconnection succeeded after host key rotation"
256                            );
257                            self.finish_connect(session).await
258                        }
259                        Err(retry_err) => {
260                            error!(
261                                error = ?retry_err,
262                                "SSH connection failed on retry after key rotation"
263                            );
264                            Err(retry_err)
265                        }
266                    }
267                } else {
268                    error!(error = ?first_err, "SSH connection failed");
269                    Err(first_err)
270                }
271            }
272        }
273    }
274
275    /// Attempt a single SSH connection (no retry logic).
276    async fn attempt_connect(
277        &self,
278        ssh_config: &Arc<client::Config>,
279        addr: &str,
280        handler: SshHandler,
281        connection_timeout: Duration,
282    ) -> Result<Handle<SshHandler>> {
283        timeout(
284            connection_timeout,
285            client::connect(ssh_config.clone(), addr, handler),
286        )
287        .await
288        .map_err(|_| {
289            error!("SSH connection timeout after {}s", CONNECTION_TIMEOUT_SECS);
290            SshMcpError::connection(format!(
291                "Connection timeout after {}s",
292                CONNECTION_TIMEOUT_SECS
293            ))
294        })?
295        .map_err(|e| SshMcpError::connection(e.to_string()))
296    }
297
298    /// Authenticate, store the session, and optionally elevate.
299    async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
300        // Authenticate
301        self.authenticate(&mut session).await?;
302
303        // Store session
304        {
305            let mut session_guard = self.session.lock().await;
306            *session_guard = Some(session);
307        }
308        {
309            let mut probe_guard = self.last_health_probe_ok_at.lock().await;
310            *probe_guard = None;
311        }
312
313        info!(
314            "Successfully connected to {}@{}:{}",
315            self.config.username, self.config.host, self.config.port
316        );
317
318        // If su_password is configured, attempt elevation
319        if self.config.su_password.is_some() {
320            debug!("su_password configured, attempting elevation...");
321            if let Err(e) = self.ensure_elevated().await {
322                warn!(error = ?e, "Failed to elevate to root. Commands will run as normal user.");
323            }
324        }
325
326        Ok(())
327    }
328
329    /// Resolve the known_hosts file path — explicit config or default.
330    fn resolve_known_hosts_path(&self) -> Option<PathBuf> {
331        self.config
332            .known_hosts
333            .clone()
334            .or_else(default_known_hosts_path)
335    }
336
337    /// Authenticate with the SSH server
338    async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
339        // Try password authentication first
340        if let Some(ref password) = self.config.password {
341            debug!(
342                "Attempting password authentication for user '{}'",
343                self.config.username
344            );
345            let auth_result = timeout(
346                Duration::from_secs(AUTH_TIMEOUT_SECS),
347                session.authenticate_password(&self.config.username, password),
348            )
349            .await
350            .map_err(|_| {
351                SshMcpError::auth(format!(
352                    "Authentication timed out after {}s",
353                    AUTH_TIMEOUT_SECS
354                ))
355            })?
356            .map_err(|e| SshMcpError::auth(e.to_string()))?;
357
358            if auth_result.success() {
359                info!("Password authentication successful");
360                return Ok(());
361            } else {
362                return Err(SshMcpError::auth("Password authentication rejected"));
363            }
364        }
365
366        // Try key authentication
367        if let Some(ref key_content) = self.config.private_key {
368            debug!(
369                "Attempting key authentication for user '{}'",
370                self.config.username
371            );
372
373            // Parse the private key using russh::keys
374            let key = Arc::new(
375                russh::keys::PrivateKey::from_openssh(key_content.as_bytes()).map_err(|e| {
376                    SshMcpError::SshKey(format!("Failed to parse private key: {}", e))
377                })?,
378            );
379
380            // For RSA, try modern rsa-sha2-256/512 first, then legacy ssh-rsa (SHA-1) as fallback.
381            // For non-RSA keys, the hash algorithm is ignored by russh.
382            let hash_attempts: &[Option<HashAlg>] = if key.algorithm().is_rsa() {
383                &[Some(HashAlg::Sha256), Some(HashAlg::Sha512), None]
384            } else {
385                &[None]
386            };
387
388            for hash_alg in hash_attempts {
389                debug!(
390                    alg = %key.algorithm(),
391                    ?hash_alg,
392                    "Attempting publickey authentication"
393                );
394
395                let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&key), *hash_alg);
396
397                let auth_result = timeout(
398                    Duration::from_secs(AUTH_TIMEOUT_SECS),
399                    session.authenticate_publickey(&self.config.username, key_with_alg),
400                )
401                .await
402                .map_err(|_| {
403                    SshMcpError::auth(format!(
404                        "Authentication timed out after {}s",
405                        AUTH_TIMEOUT_SECS
406                    ))
407                })?
408                .map_err(|e| SshMcpError::auth(e.to_string()))?;
409
410                if auth_result.success() {
411                    info!("Key authentication successful");
412                    return Ok(());
413                }
414            }
415
416            return Err(SshMcpError::auth("Key authentication rejected"));
417        }
418
419        Err(SshMcpError::auth(
420            "No authentication method available (require password or private_key)",
421        ))
422    }
423
424    /// Check if the connection is active
425    pub async fn is_connected(&self) -> bool {
426        let session_guard = self.session.lock().await;
427        session_guard.is_some()
428    }
429
430    /// Ensure connection is established, reconnecting if necessary
431    pub async fn ensure_connected(&self) -> Result<()> {
432        if !self.is_connected().await {
433            return self
434                .connect_with_retry("no active session found during ensure_connected")
435                .await;
436        }
437
438        if self.is_health_probe_fresh().await {
439            return Ok(());
440        }
441
442        let _probe_guard = self.health_probe_lock.lock().await;
443        if self.is_health_probe_fresh().await {
444            return Ok(());
445        }
446
447        if let Err(probe_error) = self.run_health_probe().await {
448            warn!(
449                error = ?probe_error,
450                "SSH health probe failed, invalidating session before reconnect"
451            );
452            self.invalidate_session("health probe failed").await;
453            self.connect_with_retry("health probe failed during ensure_connected")
454                .await?;
455        } else {
456            self.mark_health_probe_ok().await;
457        }
458
459        Ok(())
460    }
461
462    fn health_probe_ttl(&self) -> Duration {
463        let ttl_ms = self
464            .config
465            .health_probe_timeout_ms
466            .saturating_mul(2)
467            .clamp(MIN_HEALTH_PROBE_TTL_MS, MAX_HEALTH_PROBE_TTL_MS);
468        Duration::from_millis(ttl_ms)
469    }
470
471    async fn is_health_probe_fresh(&self) -> bool {
472        let guard = self.last_health_probe_ok_at.lock().await;
473        if let Some(last_ok_at) = guard.as_ref() {
474            return last_ok_at.elapsed() < self.health_probe_ttl();
475        }
476
477        false
478    }
479
480    async fn mark_health_probe_ok(&self) {
481        let mut guard = self.last_health_probe_ok_at.lock().await;
482        *guard = Some(tokio::time::Instant::now());
483    }
484
485    async fn run_health_probe(&self) -> Result<()> {
486        let ping_result = {
487            let session_guard = self.session.lock().await;
488            let session = session_guard
489                .as_ref()
490                .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
491
492            timeout(
493                Duration::from_millis(self.config.health_probe_timeout_ms),
494                session.send_ping(),
495            )
496            .await
497        };
498
499        match ping_result {
500            Ok(Ok(())) => Ok(()),
501            Ok(Err(e)) => Err(SshMcpError::connection(format!(
502                "SSH health probe ping failed: {e}"
503            ))),
504            Err(_) => Err(SshMcpError::connection(format!(
505                "SSH health probe timed out after {}ms",
506                self.config.health_probe_timeout_ms
507            ))),
508        }
509    }
510
511    async fn connect_with_retry(&self, reason: &str) -> Result<()> {
512        let max_attempts = self.config.reconnect_retries.saturating_add(1);
513        let mut attempt: u64 = 1;
514        let mut last_error: Option<SshMcpError> = None;
515
516        while attempt <= max_attempts {
517            match self.connect().await {
518                Ok(()) => {
519                    if attempt > 1 {
520                        info!(
521                            attempts = attempt,
522                            reason = reason,
523                            "SSH reconnect succeeded"
524                        );
525                    }
526                    return Ok(());
527                }
528                Err(err) => {
529                    let backoff_ms = self.backoff_for_attempt(attempt);
530                    warn!(
531                        attempt = attempt,
532                        max_attempts = max_attempts,
533                        backoff_ms = backoff_ms,
534                        reason = reason,
535                        error = ?err,
536                        "SSH reconnect attempt failed"
537                    );
538                    last_error = Some(err);
539
540                    if attempt < max_attempts && backoff_ms > 0 {
541                        sleep(Duration::from_millis(backoff_ms)).await;
542                    }
543                }
544            }
545
546            attempt = attempt.saturating_add(1);
547        }
548
549        if let Some(err) = last_error {
550            return Err(err);
551        }
552
553        Err(SshMcpError::connection(
554            "Reconnect retry loop ended without connection result",
555        ))
556    }
557
558    fn backoff_for_attempt(&self, attempt: u64) -> u64 {
559        let exponent = attempt.saturating_sub(1).min(63) as u32;
560        let factor = 1_u64 << exponent;
561        self.config
562            .reconnect_backoff_ms
563            .saturating_mul(factor)
564            .min(MAX_RECONNECT_BACKOFF_MS)
565    }
566
567    /// Get a reference to the session for operations
568    ///
569    /// Instead of cloning the Handle (which doesn't implement Clone),
570    /// we provide methods that work with the session directly.
571    pub async fn with_session<F, T>(&self, f: F) -> Result<T>
572    where
573        F: FnOnce(&Handle<SshHandler>) -> T,
574    {
575        let session_guard = self.session.lock().await;
576        match session_guard.as_ref() {
577            Some(session) => Ok(f(session)),
578            None => Err(SshMcpError::connection("SSH connection not established")),
579        }
580    }
581
582    /// Open a new session channel
583    pub async fn open_channel(&self) -> Result<Channel<client::Msg>> {
584        let session_guard = self.session.lock().await;
585        let session = session_guard
586            .as_ref()
587            .ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
588
589        let channel = session
590            .channel_open_session()
591            .await
592            .map_err(|e| SshMcpError::connection(format!("Failed to open channel: {}", e)))?;
593
594        Ok(channel)
595    }
596
597    /// Check if currently elevated to root via su
598    pub fn is_elevated(&self) -> bool {
599        self.is_elevated.load(Ordering::SeqCst)
600    }
601
602    /// Check if the `timeout` command is available on the remote system
603    ///
604    /// Uses cached result after first check. To trigger a new check,
605    /// the connection must be re-established.
606    pub fn use_timeout_wrapper(&self) -> bool {
607        self.has_timeout_cmd.load(Ordering::SeqCst)
608    }
609
610    /// Disables timeout wrapper for the rest of this connection lifetime
611    ///
612    /// When called, this sets `has_timeout_cmd` to false, causing all subsequent
613    /// commands to fall back to the tokio timeout + pkill method instead of using
614    /// the remote timeout command wrapper.
615    pub fn disable_timeout_wrapper(&self) {
616        self.has_timeout_cmd.store(false, Ordering::SeqCst);
617        warn!("timeout wrapper disabled due to errors, falling back to pkill");
618    }
619
620    /// Lazily check and return whether to use the remote timeout wrapper
621    ///
622    /// This performs a one-time remote detection on first need and then returns
623    /// the cached decision for the lifetime of the connection.
624    pub(crate) async fn determine_timeout_wrapper_usage(&self) -> bool {
625        if self.use_timeout_wrapper() {
626            return true;
627        }
628
629        let _ = self.check_timeout_availability().await;
630        self.use_timeout_wrapper()
631    }
632
633    /// Detect whether the `timeout` command is available on the remote system
634    ///
635    /// This performs a one-time detection check by running
636    /// `sh -c 'command -v timeout'`
637    /// on the remote system. The result is cached for the lifetime of the
638    /// connection.
639    ///
640    /// Returns true if timeout is available, false otherwise.
641    pub async fn check_timeout_availability(&self) -> bool {
642        // Check cache first
643        if self.has_timeout_cmd.load(Ordering::SeqCst) {
644            return true;
645        }
646
647        // Open a new channel for detection
648        let mut channel = match self.open_channel().await {
649            Ok(ch) => ch,
650            Err(e) => {
651                debug!(error = ?e, "Failed to open channel for timeout detection");
652                return false;
653            }
654        };
655
656        // Run detection command
657        let exec_result = channel
658            .exec(true, "sh -c 'command -v timeout'")
659            .await
660            .map_err(|e| {
661                SshMcpError::connection(format!("Failed to exec detection command: {}", e))
662            });
663
664        if exec_result.is_err() {
665            debug!("Failed to exec timeout detection command");
666            return false;
667        }
668
669        // Collect output
670        let mut output = String::new();
671        while let Some(msg) = channel.wait().await {
672            match msg {
673                ChannelMsg::Data { data } => {
674                    output.push_str(&String::from_utf8_lossy(&data));
675                }
676                ChannelMsg::Close | ChannelMsg::Eof => {
677                    break;
678                }
679                _ => {
680                    // Ignore other messages
681                }
682            }
683        }
684
685        // If timeout command exists, output contains its path (e.g., /usr/bin/timeout)
686        let available = !output.is_empty();
687        self.has_timeout_cmd.store(available, Ordering::SeqCst);
688
689        if available {
690            info!("timeout command available on remote host");
691        } else {
692            info!("timeout command NOT available, using fallback pkill");
693        }
694
695        available
696    }
697
698    /// Check if an elevated su channel is available
699    pub async fn has_su_channel(&self) -> bool {
700        let channel_guard = self.su_channel.lock().await;
701        channel_guard.is_some()
702    }
703
704    /// Execute a closure with access to the su channel
705    ///
706    /// The closure receives a mutable reference to the Option<Channel>,
707    /// allowing it to use the channel for operations.
708    pub async fn with_su_channel<F, Fut, T>(&self, f: F) -> Result<T>
709    where
710        F: FnOnce(&mut Option<Channel<client::Msg>>) -> Fut,
711        Fut: std::future::Future<Output = Result<T>>,
712    {
713        let mut channel_guard = self.su_channel.lock().await;
714        f(&mut channel_guard).await
715    }
716
717    /// Ensure we have an elevated shell via `su`
718    ///
719    /// This starts an interactive PTY session, runs `su -`, sends the password,
720    /// and waits for the root prompt (#).
721    pub async fn ensure_elevated(&self) -> Result<()> {
722        // Already elevated?
723        if self.is_elevated.load(Ordering::SeqCst) {
724            let channel_guard = self.su_channel.lock().await;
725            if channel_guard.is_some() {
726                return Ok(());
727            }
728        }
729
730        // Need su_password
731        let su_password = self
732            .config
733            .su_password
734            .clone()
735            .ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
736
737        // Open a channel for PTY shell
738        let channel = self
739            .open_channel()
740            .await
741            .map_err(|e| SshMcpError::elevation_failed(format!("Failed to open channel: {}", e)))?;
742
743        debug!("Opened channel for su elevation");
744
745        // Request PTY
746        channel
747            .request_pty(
748                true, // want_reply
749                "xterm",
750                80,  // cols
751                24,  // rows
752                0,   // pixel width
753                0,   // pixel height
754                &[], // terminal modes
755            )
756            .await
757            .map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
758
759        debug!("PTY requested");
760
761        // Request shell
762        channel.request_shell(true).await.map_err(|e| {
763            SshMcpError::elevation_failed(format!("Failed to request shell: {}", e))
764        })?;
765
766        debug!("Shell requested, starting su elevation...");
767
768        // Send "su -\n" command
769        channel.data(b"su -\n".as_slice()).await.map_err(|e| {
770            SshMcpError::elevation_failed(format!("Failed to send su command: {}", e))
771        })?;
772
773        // Wait for password prompt and respond
774        let elevation_result = self.handle_su_elevation(channel, &su_password).await;
775
776        match elevation_result {
777            Ok(elevated_channel) => {
778                // Store the elevated channel
779                let mut channel_guard = self.su_channel.lock().await;
780                *channel_guard = Some(elevated_channel);
781                self.is_elevated.store(true, Ordering::SeqCst);
782                info!("Successfully elevated to root via su");
783                Ok(())
784            }
785            Err(e) => {
786                self.is_elevated.store(false, Ordering::SeqCst);
787                Err(e)
788            }
789        }
790    }
791
792    /// Handle the interactive su elevation process
793    async fn handle_su_elevation(
794        &self,
795        mut channel: Channel<client::Msg>,
796        password: &str,
797    ) -> Result<Channel<client::Msg>> {
798        use russh::ChannelMsg;
799
800        let elevation_timeout = Duration::from_secs(10);
801        let mut buffer = String::new();
802        let mut password_sent = false;
803
804        let deadline = tokio::time::Instant::now() + elevation_timeout;
805
806        loop {
807            // Check timeout
808            if tokio::time::Instant::now() > deadline {
809                return Err(SshMcpError::elevation_failed("su elevation timed out"));
810            }
811
812            // Wait for messages with timeout
813            let wait_result =
814                tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
815
816            match wait_result {
817                Ok(Some(msg)) => {
818                    match msg {
819                        ChannelMsg::Data { data } => {
820                            let text = String::from_utf8_lossy(&data);
821                            buffer.push_str(&text);
822                            debug!(su_buffer_len = buffer.len(), "su buffer received");
823
824                            // Check for password prompt
825                            if !password_sent && buffer.to_lowercase().contains("password") {
826                                debug!("Password prompt detected, sending password...");
827                                channel
828                                    .data(format!("{}\n", password).as_bytes())
829                                    .await
830                                    .map_err(|e| {
831                                        SshMcpError::elevation_failed(format!(
832                                            "Failed to send password: {}",
833                                            e
834                                        ))
835                                    })?;
836                                password_sent = true;
837                                // Clear buffer to avoid re-matching password prompt
838                                buffer.clear();
839                            }
840
841                            // Check for root prompt after password sent
842                            if password_sent && buffer.contains('#') {
843                                debug!("Root prompt detected, elevation successful");
844                                return Ok(channel);
845                            }
846
847                            // Check for authentication failure
848                            if buffer.to_lowercase().contains("authentication failure")
849                                || buffer.to_lowercase().contains("incorrect password")
850                                || buffer.to_lowercase().contains("su: failed")
851                                || buffer.to_lowercase().contains("su: authentication")
852                            {
853                                return Err(SshMcpError::elevation_failed(format!(
854                                    "su authentication failed: {}",
855                                    buffer
856                                )));
857                            }
858                        }
859                        ChannelMsg::Close => {
860                            return Err(SshMcpError::elevation_failed(
861                                "Channel closed before elevation completed",
862                            ));
863                        }
864                        _ => {
865                            // Ignore other messages
866                        }
867                    }
868                }
869                Ok(None) => {
870                    // Channel ended
871                    return Err(SshMcpError::elevation_failed(
872                        "Channel ended before elevation completed",
873                    ));
874                }
875                Err(_) => {
876                    // Timeout on wait, continue loop
877                    continue;
878                }
879            }
880        }
881    }
882
883    /// Get the su password if configured
884    pub fn get_su_password(&self) -> Option<&str> {
885        self.config.su_password.as_deref()
886    }
887
888    /// Get the sudo password if configured
889    pub fn get_sudo_password(&self) -> Option<&str> {
890        self.config.sudo_password.as_deref()
891    }
892
893    /// Set or update the su password
894    ///
895    /// If setting a new password, will attempt to establish elevation.
896    /// If clearing the password (None), will close any existing su shell.
897    pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
898        // Note: We can't modify self.config directly since we only have &self
899        // In the TypeScript version, this modifies the config and triggers elevation.
900        // For Rust, we'd need interior mutability. For now, just attempt elevation
901        // if password is provided.
902
903        if password.is_some() {
904            // Attempt elevation with the current config
905            // In a real implementation, we'd need to update config first
906            self.ensure_elevated().await?;
907        } else {
908            // Clear elevation state
909            let mut channel_guard = self.su_channel.lock().await;
910            if let Some(ch) = channel_guard.take() {
911                // Try to close the channel gracefully
912                let _ = ch.eof().await;
913            }
914            self.is_elevated.store(false, Ordering::SeqCst);
915        }
916
917        Ok(())
918    }
919
920    /// Close the SSH connection
921    pub async fn close(&self) {
922        // Close su channel if exists
923        {
924            let mut channel_guard = self.su_channel.lock().await;
925            if let Some(ch) = channel_guard.take() {
926                let _ = ch.eof().await;
927            }
928        }
929        self.is_elevated.store(false, Ordering::SeqCst);
930
931        // Close main session
932        {
933            let mut session_guard = self.session.lock().await;
934            if let Some(session) = session_guard.take() {
935                let _ = session
936                    .disconnect(russh::Disconnect::ByApplication, "", "")
937                    .await;
938            }
939        }
940
941        {
942            let mut probe_guard = self.last_health_probe_ok_at.lock().await;
943            *probe_guard = None;
944        }
945
946        info!("SSH connection closed");
947    }
948
949    /// Invalidate the current session and clear elevation state
950    ///
951    /// This clears the session handle, su_channel, and resets elevation state.
952    /// Used when a connection is detected as broken and needs reconnection.
953    pub async fn invalidate_session(&self, reason: &str) {
954        warn!(reason = ?reason, "Invalidating SSH session");
955
956        // Take channel out of mutex before awaiting to avoid deadlock
957        let channel = {
958            let mut channel_guard = self.su_channel.lock().await;
959            channel_guard.take()
960        };
961
962        // Drop lock before awaiting EOF
963        if let Some(ch) = channel {
964            let _ = ch.eof().await;
965        }
966        self.is_elevated.store(false, Ordering::SeqCst);
967
968        // Attempt best-effort graceful disconnect with short timeout.
969        let session = {
970            let mut session_guard = self.session.lock().await;
971            session_guard.take()
972        };
973
974        if let Some(session) = session {
975            let _ = tokio::time::timeout(
976                Duration::from_millis(500),
977                session.disconnect(russh::Disconnect::ByApplication, "", ""),
978            )
979            .await;
980        }
981
982        {
983            let mut probe_guard = self.last_health_probe_ok_at.lock().await;
984            *probe_guard = None;
985        }
986
987        debug!(reason = ?reason, "Session invalidated");
988    }
989
990    /// Force a reconnection by invalidating the current session and reconnecting
991    ///
992    /// This is used when the connection is known to be broken and a fresh
993    /// connection is required. It clears all session state and performs
994    /// a new connection attempt.
995    pub async fn reconnect(&self) -> Result<()> {
996        self.invalidate_session("explicit reconnect requested")
997            .await;
998        self.connect_with_retry("explicit reconnect requested")
999            .await
1000    }
1001}
1002
1003impl std::fmt::Debug for SshConnectionManager {
1004    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1005        f.debug_struct("SshConnectionManager")
1006            .field("host", &self.config.host)
1007            .field("port", &self.config.port)
1008            .field("username", &self.config.username)
1009            .field("is_connecting", &self.is_connecting.load(Ordering::SeqCst))
1010            .field("is_elevated", &self.is_elevated.load(Ordering::SeqCst))
1011            .field(
1012                "has_timeout_cmd",
1013                &self.has_timeout_cmd.load(Ordering::SeqCst),
1014            )
1015            .finish()
1016    }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    #[tokio::test]
1024    async fn test_connection_manager_creation() {
1025        let config = SshConfig::new("localhost", "testuser")
1026            .with_port(22)
1027            .with_password("testpass");
1028
1029        let manager = SshConnectionManager::new(config).await;
1030
1031        assert!(!manager.is_connected().await);
1032        assert!(!manager.is_elevated());
1033    }
1034
1035    #[tokio::test]
1036    async fn test_not_connected_initially() {
1037        let config = SshConfig::new("localhost", "testuser");
1038        let manager = SshConnectionManager::new(config).await;
1039
1040        // Should return error when trying to open channel without connecting
1041        let result = manager.open_channel().await;
1042        assert!(result.is_err());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_cancelled_connect_releases_owner_flag() {
1047        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1048            .await
1049            .expect("bind test listener");
1050        let port = listener.local_addr().expect("test listener address").port();
1051        let accept_task = tokio::spawn(async move {
1052            let (_stream, _) = listener.accept().await.expect("accept test connection");
1053            std::future::pending::<()>().await;
1054        });
1055        let config = SshConfig::new("127.0.0.1", "testuser")
1056            .with_port(port)
1057            .with_password("testpass");
1058        let manager = SshConnectionManager::new(config).await;
1059
1060        let result = timeout(Duration::from_millis(100), manager.connect()).await;
1061        assert!(result.is_err(), "silent peer should keep connect in flight");
1062        assert!(
1063            !manager.is_connecting.load(Ordering::SeqCst),
1064            "cancelling connect must release the owner flag"
1065        );
1066
1067        accept_task.abort();
1068    }
1069}