Skip to main content

ssh_commander_core/
connection_manager.rs

1#[cfg(feature = "desktop")]
2use crate::desktop_protocol::{DesktopConnectRequest, DesktopProtocol, FrameUpdate};
3#[cfg(feature = "ftp")]
4use crate::ftp_client::FtpClient;
5#[cfg(feature = "desktop")]
6use crate::rdp_client::RdpClient;
7#[cfg(feature = "sftp")]
8use crate::sftp_client::StandaloneSftpClient;
9#[cfg(feature = "ssh")]
10use crate::ssh::{HostKeyStore, PtySession, SshClient, SshConfig};
11#[cfg(all(feature = "postgres", feature = "ssh"))]
12use crate::ssh::{SshTunnel, SshTunnelRef};
13#[cfg(feature = "desktop")]
14use crate::vnc_client::VncClient;
15use anyhow::Result;
16#[cfg(feature = "postgres")]
17use ssh_commander_pg::{PgConfig, PgPool};
18use std::collections::HashMap;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21#[cfg(feature = "desktop")]
22use tokio::sync::mpsc;
23use tokio_util::sync::CancellationToken;
24
25/// Canonical protocol tag for a managed connection.
26///
27/// Using an enum instead of a free-form string means every branch that inspects
28/// a connection is exhaustiveness-checked and callers can't typo a tag.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ProtocolKind {
31    #[cfg(feature = "ssh")]
32    Ssh,
33    #[cfg(feature = "sftp")]
34    Sftp,
35    #[cfg(feature = "ftp")]
36    Ftp,
37    #[cfg(feature = "desktop")]
38    Rdp,
39    #[cfg(feature = "desktop")]
40    Vnc,
41    #[cfg(feature = "postgres")]
42    Postgres,
43}
44
45impl ProtocolKind {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            #[cfg(feature = "ssh")]
49            ProtocolKind::Ssh => "SSH",
50            #[cfg(feature = "sftp")]
51            ProtocolKind::Sftp => "SFTP",
52            #[cfg(feature = "ftp")]
53            ProtocolKind::Ftp => "FTP",
54            #[cfg(feature = "desktop")]
55            ProtocolKind::Rdp => "RDP",
56            #[cfg(feature = "desktop")]
57            ProtocolKind::Vnc => "VNC",
58            #[cfg(feature = "postgres")]
59            ProtocolKind::Postgres => "POSTGRES",
60        }
61    }
62}
63
64/// A single managed connection, tagged by protocol.
65///
66/// Each variant owns its own `Arc<RwLock<_>>` — giving per-connection locking
67/// granularity, instead of a global map-level RwLock that would serialise
68/// every operation across unrelated connections.
69pub enum ManagedConnection {
70    #[cfg(feature = "ssh")]
71    Ssh(Arc<RwLock<SshClient>>),
72    #[cfg(feature = "sftp")]
73    Sftp(Arc<RwLock<StandaloneSftpClient>>),
74    #[cfg(feature = "ftp")]
75    Ftp(Arc<RwLock<FtpClient>>),
76    #[cfg(feature = "desktop")]
77    Desktop {
78        kind: ProtocolKind, // Rdp or Vnc
79        client: Arc<RwLock<Box<dyn DesktopProtocol>>>,
80    },
81    /// `PgPool` is internally `Sync` (manages its own locks), so no
82    /// outer `RwLock` is needed here — multiple sessions / tabs can
83    /// hit the pool concurrently from independent tasks. `tunnel` holds
84    /// the SSH local-forward open for the pool's lifetime when the
85    /// profile connects through one; the pool itself dials its local
86    /// port and is unaware of SSH.
87    #[cfg(feature = "postgres")]
88    Postgres {
89        pool: Arc<PgPool>,
90        #[cfg(feature = "ssh")]
91        tunnel: Option<Arc<SshTunnel>>,
92    },
93}
94
95impl ManagedConnection {
96    pub fn kind(&self) -> ProtocolKind {
97        match self {
98            #[cfg(feature = "ssh")]
99            ManagedConnection::Ssh(_) => ProtocolKind::Ssh,
100            #[cfg(feature = "sftp")]
101            ManagedConnection::Sftp(_) => ProtocolKind::Sftp,
102            #[cfg(feature = "ftp")]
103            ManagedConnection::Ftp(_) => ProtocolKind::Ftp,
104            #[cfg(feature = "desktop")]
105            ManagedConnection::Desktop { kind, .. } => *kind,
106            #[cfg(feature = "postgres")]
107            ManagedConnection::Postgres { .. } => ProtocolKind::Postgres,
108        }
109    }
110}
111
112#[cfg(any(
113    feature = "ssh",
114    feature = "sftp",
115    feature = "ftp",
116    feature = "desktop",
117    feature = "postgres"
118))]
119#[derive(Debug, Clone, Copy)]
120#[allow(dead_code)] // some variants only exist under specific feature sets
121enum ConnectionSlotKind {
122    #[cfg(feature = "ssh")]
123    Ssh,
124    #[cfg(feature = "sftp")]
125    Sftp,
126    #[cfg(feature = "ftp")]
127    Ftp,
128    #[cfg(feature = "desktop")]
129    Desktop,
130    #[cfg(feature = "postgres")]
131    Postgres,
132}
133
134#[cfg(any(
135    feature = "ssh",
136    feature = "sftp",
137    feature = "ftp",
138    feature = "desktop",
139    feature = "postgres"
140))]
141impl ConnectionSlotKind {
142    fn label(self) -> &'static str {
143        match self {
144            #[cfg(feature = "ssh")]
145            ConnectionSlotKind::Ssh => "SSH",
146            #[cfg(feature = "sftp")]
147            ConnectionSlotKind::Sftp => "SFTP",
148            #[cfg(feature = "ftp")]
149            ConnectionSlotKind::Ftp => "FTP",
150            #[cfg(feature = "desktop")]
151            ConnectionSlotKind::Desktop => "desktop",
152            #[cfg(feature = "postgres")]
153            ConnectionSlotKind::Postgres => "postgres",
154        }
155    }
156
157    fn matches(self, connection: &ManagedConnection) -> bool {
158        match self {
159            #[cfg(feature = "ssh")]
160            ConnectionSlotKind::Ssh => matches!(connection, ManagedConnection::Ssh(_)),
161            #[cfg(feature = "sftp")]
162            ConnectionSlotKind::Sftp => matches!(connection, ManagedConnection::Sftp(_)),
163            #[cfg(feature = "ftp")]
164            ConnectionSlotKind::Ftp => matches!(connection, ManagedConnection::Ftp(_)),
165            #[cfg(feature = "desktop")]
166            ConnectionSlotKind::Desktop => {
167                matches!(connection, ManagedConnection::Desktop { .. })
168            }
169            #[cfg(feature = "postgres")]
170            ConnectionSlotKind::Postgres => {
171                matches!(connection, ManagedConnection::Postgres { .. })
172            }
173        }
174    }
175}
176
177/// The connection manager owns the mapping from connection_id → its backing
178/// protocol state. Previously this was eight parallel hashmaps held together
179/// by convention; invariants (e.g. "if connection_types says SFTP, the sftp
180/// hashmap contains the id") are now enforced by the variant tag itself.
181pub struct ConnectionManager {
182    connections: Arc<RwLock<HashMap<String, ManagedConnection>>>,
183    /// PTY session state — only present when the SSH feature is enabled,
184    /// since interactive shells are an SSH-only capability.
185    #[cfg(feature = "ssh")]
186    pty_sessions: Arc<RwLock<HashMap<String, Arc<PtySession>>>>,
187    /// Generation counter per connection_id — incremented on each StartPty.
188    /// Used to prevent a stale Close from killing a newly created session.
189    #[cfg(feature = "ssh")]
190    pty_generations: Arc<RwLock<HashMap<String, u64>>>,
191    pending_connections: Arc<RwLock<HashMap<String, CancellationToken>>>,
192    /// Shared TOFU host-key store used by every SSH/SFTP connection.
193    #[cfg(feature = "ssh")]
194    host_keys: Arc<HostKeyStore>,
195}
196
197impl Default for ConnectionManager {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl ConnectionManager {
204    #[cfg(feature = "ssh")]
205    pub fn new() -> Self {
206        Self::with_host_keys(Arc::new(HostKeyStore::new(HostKeyStore::default_path())))
207    }
208
209    /// Construct a manager for a build without the SSH feature. There is
210    /// no host-key store or PTY state to initialise.
211    #[cfg(not(feature = "ssh"))]
212    pub fn new() -> Self {
213        Self {
214            connections: Arc::new(RwLock::new(HashMap::new())),
215            pending_connections: Arc::new(RwLock::new(HashMap::new())),
216        }
217    }
218
219    #[cfg(feature = "ssh")]
220    pub fn with_host_keys(host_keys: Arc<HostKeyStore>) -> Self {
221        Self {
222            connections: Arc::new(RwLock::new(HashMap::new())),
223            pty_sessions: Arc::new(RwLock::new(HashMap::new())),
224            pty_generations: Arc::new(RwLock::new(HashMap::new())),
225            pending_connections: Arc::new(RwLock::new(HashMap::new())),
226            host_keys,
227        }
228    }
229
230    /// Access the shared host-key store. Used by the macOS bridge to
231    /// expose `forget` over FFI for the "Trust new key" flow on a
232    /// `HostKeyMismatch`.
233    #[cfg(feature = "ssh")]
234    pub fn host_keys(&self) -> Arc<HostKeyStore> {
235        self.host_keys.clone()
236    }
237
238    // =========================================================================
239    // Inspection
240    // =========================================================================
241
242    /// Protocol of an existing connection, or None if not registered.
243    pub async fn connection_kind(&self, id: &str) -> Option<ProtocolKind> {
244        let connections = self.connections.read().await;
245        connections.get(id).map(|c| c.kind())
246    }
247
248    /// Backward-compatible string form of `connection_kind`. Returns "SSH",
249    /// "SFTP", "FTP", "RDP", or "VNC". Prefer `connection_kind` in new code.
250    pub async fn get_connection_type(&self, id: &str) -> Option<String> {
251        self.connection_kind(id)
252            .await
253            .map(|k| k.as_str().to_string())
254    }
255
256    pub async fn list_connections(&self) -> Vec<String> {
257        let connections = self.connections.read().await;
258        connections.keys().cloned().collect()
259    }
260
261    /// Return the SSH client for a connection if it is an SSH connection.
262    #[cfg(feature = "ssh")]
263    pub async fn get_connection(&self, id: &str) -> Option<Arc<RwLock<SshClient>>> {
264        let connections = self.connections.read().await;
265        match connections.get(id) {
266            Some(ManagedConnection::Ssh(c)) => Some(c.clone()),
267            _ => None,
268        }
269    }
270
271    #[cfg(feature = "sftp")]
272    pub async fn get_sftp_client(&self, id: &str) -> Option<Arc<RwLock<StandaloneSftpClient>>> {
273        let connections = self.connections.read().await;
274        match connections.get(id) {
275            Some(ManagedConnection::Sftp(c)) => Some(c.clone()),
276            _ => None,
277        }
278    }
279
280    #[cfg(feature = "ftp")]
281    pub async fn get_ftp_client(&self, id: &str) -> Option<Arc<RwLock<FtpClient>>> {
282        let connections = self.connections.read().await;
283        match connections.get(id) {
284            Some(ManagedConnection::Ftp(c)) => Some(c.clone()),
285            _ => None,
286        }
287    }
288
289    #[cfg(feature = "desktop")]
290    pub async fn get_desktop_connection(
291        &self,
292        id: &str,
293    ) -> Option<Arc<RwLock<Box<dyn DesktopProtocol>>>> {
294        let connections = self.connections.read().await;
295        match connections.get(id) {
296            Some(ManagedConnection::Desktop { client, .. }) => Some(client.clone()),
297            _ => None,
298        }
299    }
300
301    #[cfg(feature = "postgres")]
302    pub async fn get_postgres_pool(&self, id: &str) -> Option<Arc<PgPool>> {
303        let connections = self.connections.read().await;
304        match connections.get(id) {
305            Some(ManagedConnection::Postgres { pool, .. }) => Some(pool.clone()),
306            _ => None,
307        }
308    }
309
310    // =========================================================================
311    // SSH connection lifecycle (supports cancellation of a pending connect)
312    // =========================================================================
313
314    #[cfg(feature = "ssh")]
315    pub async fn create_connection(&self, connection_id: String, config: SshConfig) -> Result<()> {
316        let mut client = SshClient::new(self.host_keys.clone());
317        let cancel_token = self.register_pending_connection(&connection_id).await;
318
319        let connect_result = tokio::select! {
320            res = client.connect(&config) => res,
321            _ = cancel_token.cancelled() => Err(anyhow::anyhow!("Connection cancelled by user")),
322        };
323
324        self.clear_pending_connection(&connection_id).await;
325
326        connect_result?;
327        self.replace_managed_connection(
328            connection_id,
329            ManagedConnection::Ssh(Arc::new(RwLock::new(client))),
330        )
331        .await
332    }
333
334    // Cancellable-connect bookkeeping is only exercised by the SSH and
335    // Postgres connect paths; a build without either does not need it.
336    #[cfg(any(feature = "ssh", feature = "postgres"))]
337    async fn register_pending_connection(&self, connection_id: &str) -> CancellationToken {
338        let token = CancellationToken::new();
339        let mut pending = self.pending_connections.write().await;
340        pending.insert(connection_id.to_string(), token.clone());
341        token
342    }
343
344    #[cfg(any(feature = "ssh", feature = "postgres"))]
345    async fn clear_pending_connection(&self, connection_id: &str) {
346        let mut pending = self.pending_connections.write().await;
347        pending.remove(connection_id);
348    }
349
350    #[cfg(any(
351        feature = "ssh",
352        feature = "sftp",
353        feature = "ftp",
354        feature = "desktop",
355        feature = "postgres"
356    ))]
357    async fn disconnect_managed_connection(
358        &self,
359        connection_id: &str,
360        connection: ManagedConnection,
361    ) -> Result<()> {
362        match connection {
363            #[cfg(feature = "ssh")]
364            ManagedConnection::Ssh(client) => {
365                {
366                    let mut pty_sessions = self.pty_sessions.write().await;
367                    if let Some(session) = pty_sessions.remove(connection_id) {
368                        session.cancel.cancel();
369                    }
370                }
371                {
372                    let mut generations = self.pty_generations.write().await;
373                    generations.remove(connection_id);
374                }
375                let mut client = client.write().await;
376                client.disconnect().await?;
377            }
378            #[cfg(feature = "sftp")]
379            ManagedConnection::Sftp(client) => {
380                let mut client = client.write().await;
381                client.disconnect().await?;
382            }
383            #[cfg(feature = "ftp")]
384            ManagedConnection::Ftp(client) => {
385                let mut client = client.write().await;
386                client.disconnect().await?;
387            }
388            #[cfg(feature = "desktop")]
389            ManagedConnection::Desktop { client, .. } => {
390                let mut client = client.write().await;
391                client.disconnect().await?;
392            }
393            #[cfg(feature = "postgres")]
394            ManagedConnection::Postgres { pool, .. } => {
395                pool.shutdown().await;
396                // `tunnel` (if any) is dropped with the ManagedConnection,
397                // cancelling the SSH local-forward accept loop.
398            }
399        }
400        // `connection_id` is unused when no protocol arm consumes it (e.g.
401        // a postgres-only build), so keep it referenced to avoid a warning.
402        let _ = connection_id;
403        Ok(())
404    }
405
406    #[cfg(any(
407        feature = "ssh",
408        feature = "sftp",
409        feature = "ftp",
410        feature = "desktop",
411        feature = "postgres"
412    ))]
413    async fn replace_managed_connection(
414        &self,
415        connection_id: String,
416        replacement: ManagedConnection,
417    ) -> Result<()> {
418        let previous = {
419            let mut connections = self.connections.write().await;
420            connections.remove(&connection_id)
421        };
422
423        if let Some(previous) = previous {
424            self.disconnect_managed_connection(&connection_id, previous)
425                .await?;
426        }
427
428        let mut connections = self.connections.write().await;
429        connections.insert(connection_id, replacement);
430        Ok(())
431    }
432
433    #[cfg(any(
434        feature = "ssh",
435        feature = "sftp",
436        feature = "ftp",
437        feature = "desktop",
438        feature = "postgres"
439    ))]
440    async fn take_connection_if_kind(
441        &self,
442        connection_id: &str,
443        expected: ConnectionSlotKind,
444    ) -> Result<Option<ManagedConnection>> {
445        let mut connections = self.connections.write().await;
446        let Some(current) = connections.get(connection_id) else {
447            return Ok(None);
448        };
449
450        if !expected.matches(current) {
451            return Err(anyhow::anyhow!(
452                "Connection '{}' is {}, not {}",
453                connection_id,
454                current.kind().as_str(),
455                expected.label()
456            ));
457        }
458
459        Ok(connections.remove(connection_id))
460    }
461
462    pub async fn cancel_pending_connection(&self, connection_id: &str) -> bool {
463        let mut pending = self.pending_connections.write().await;
464        if let Some(token) = pending.remove(connection_id) {
465            token.cancel();
466            true
467        } else {
468            false
469        }
470    }
471
472    /// Close the SSH connection for `connection_id` (if it is SSH). Also tears
473    /// down any associated PTY session and prunes the generation counter so it
474    /// cannot leak across reconnects.
475    #[cfg(feature = "ssh")]
476    pub async fn close_connection(&self, connection_id: &str) -> Result<()> {
477        if let Some(connection) = self
478            .take_connection_if_kind(connection_id, ConnectionSlotKind::Ssh)
479            .await?
480        {
481            self.disconnect_managed_connection(connection_id, connection)
482                .await?;
483        }
484        Ok(())
485    }
486
487    // =========================================================================
488    // PTY (interactive shell) management — only valid on SSH connections.
489    // =========================================================================
490
491    /// Start a PTY shell connection (like ttyd does).
492    /// Enables interactive commands: vim, less, more, top, htop, etc.
493    #[cfg(feature = "ssh")]
494    pub async fn start_pty_connection(
495        &self,
496        connection_id: &str,
497        cols: u32,
498        rows: u32,
499    ) -> Result<u64> {
500        let client = self
501            .get_connection(connection_id)
502            .await
503            .ok_or_else(|| anyhow::anyhow!("Connection not found"))?;
504
505        // Cancel and remove any existing PTY session for this connection first.
506        // This ensures the old SSH channel and reader task are torn down before
507        // we create a new one, preventing orphaned sessions.
508        {
509            let mut pty_sessions = self.pty_sessions.write().await;
510            if let Some(old_session) = pty_sessions.remove(connection_id) {
511                old_session.cancel.cancel();
512                tracing::info!("Cancelled old PTY session for {}", connection_id);
513            }
514        }
515
516        let pty = {
517            let client = client.read().await;
518            client.create_pty_session(cols, rows).await?
519        };
520
521        // Bump generation so any in-flight Close for the old session is ignored.
522        let mut generations = self.pty_generations.write().await;
523        let generation_entry = generations.entry(connection_id.to_string()).or_insert(0);
524        *generation_entry += 1;
525        let current_gen = *generation_entry;
526        drop(generations);
527
528        let mut pty_sessions = self.pty_sessions.write().await;
529        pty_sessions.insert(connection_id.to_string(), Arc::new(pty));
530
531        Ok(current_gen)
532    }
533
534    /// Send data to PTY (user input).
535    ///
536    /// Backpressure: if the input channel is full we await `send`, preserving
537    /// keystroke order.
538    #[cfg(feature = "ssh")]
539    pub async fn write_to_pty(&self, connection_id: &str, data: Vec<u8>) -> Result<()> {
540        let tx = {
541            let pty_sessions = self.pty_sessions.read().await;
542            let pty = pty_sessions
543                .get(connection_id)
544                .ok_or_else(|| anyhow::anyhow!("PTY connection not found"))?;
545            pty.input_tx.clone()
546        };
547
548        tx.send(data)
549            .await
550            .map_err(|_| anyhow::anyhow!("PTY channel closed"))
551    }
552
553    /// Capture the active `PtySession` for a connection. Used by the macOS
554    /// bridge to spawn an output-forwarder task that holds a stable handle
555    /// to the session's `output_rx` for the lifetime of that PTY, even if
556    /// `start_pty_connection` is later called again for the same connection
557    /// (which would replace the entry in `pty_sessions`).
558    #[cfg(feature = "ssh")]
559    pub async fn get_pty_session(&self, connection_id: &str) -> Option<Arc<PtySession>> {
560        self.pty_sessions.read().await.get(connection_id).cloned()
561    }
562
563    /// Read a burst of PTY output — blocks until data arrives, then drains any
564    /// additional already-queued chunks up to `max_bytes`.
565    #[cfg(feature = "ssh")]
566    pub async fn read_pty_burst(&self, connection_id: &str, max_bytes: usize) -> Result<Vec<u8>> {
567        let pty = {
568            let pty_sessions = self.pty_sessions.read().await;
569            pty_sessions
570                .get(connection_id)
571                .cloned()
572                .ok_or_else(|| anyhow::anyhow!("PTY connection not found"))?
573        };
574
575        let mut rx = pty.output_rx.lock().await;
576
577        let mut out = match rx.recv().await {
578            Some(data) => data,
579            None => return Err(anyhow::anyhow!("PTY connection closed")),
580        };
581
582        while out.len() < max_bytes {
583            match rx.try_recv() {
584                Ok(more) => out.extend_from_slice(&more),
585                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
586                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
587            }
588        }
589
590        Ok(out)
591    }
592
593    /// Close PTY connection, but only if the generation matches.
594    #[cfg(feature = "ssh")]
595    pub async fn close_pty_connection(
596        &self,
597        connection_id: &str,
598        expected_gen: Option<u64>,
599    ) -> Result<()> {
600        if let Some(expected_generation) = expected_gen {
601            let generations = self.pty_generations.read().await;
602            let current_gen = generations.get(connection_id).copied().unwrap_or(0);
603            if current_gen != expected_generation {
604                tracing::info!(
605                    "Ignoring stale Close for {} (gen {} != current {})",
606                    connection_id,
607                    expected_generation,
608                    current_gen
609                );
610                return Ok(());
611            }
612        }
613        let mut pty_sessions = self.pty_sessions.write().await;
614        if let Some(session) = pty_sessions.remove(connection_id) {
615            session.cancel.cancel();
616        }
617        Ok(())
618    }
619
620    /// Get the cancellation token for a PTY session (used by WebSocket reader tasks).
621    #[cfg(feature = "ssh")]
622    pub async fn get_pty_cancel_token(&self, connection_id: &str) -> Option<CancellationToken> {
623        let sessions = self.pty_sessions.read().await;
624        sessions.get(connection_id).map(|s| s.cancel.clone())
625    }
626
627    /// Resize PTY terminal (send window-change to remote SSH channel)
628    #[cfg(feature = "ssh")]
629    pub async fn resize_pty(&self, connection_id: &str, cols: u32, rows: u32) -> Result<()> {
630        let pty_sessions = self.pty_sessions.read().await;
631        let pty = pty_sessions
632            .get(connection_id)
633            .ok_or_else(|| anyhow::anyhow!("PTY connection not found"))?;
634
635        pty.resize_tx
636            .send((cols, rows))
637            .await
638            .map_err(|_| anyhow::anyhow!("PTY resize channel closed"))
639    }
640
641    // =========================================================================
642    // Standalone SFTP
643    // =========================================================================
644
645    #[cfg(feature = "sftp")]
646    pub async fn create_sftp_connection(
647        &self,
648        connection_id: String,
649        config: crate::sftp_client::SftpConfig,
650    ) -> Result<()> {
651        let client = StandaloneSftpClient::connect(&config, self.host_keys.clone()).await?;
652        self.replace_managed_connection(
653            connection_id,
654            ManagedConnection::Sftp(Arc::new(RwLock::new(client))),
655        )
656        .await
657    }
658
659    #[cfg(feature = "sftp")]
660    pub async fn close_sftp_connection(&self, connection_id: &str) -> Result<()> {
661        if let Some(connection) = self
662            .take_connection_if_kind(connection_id, ConnectionSlotKind::Sftp)
663            .await?
664        {
665            self.disconnect_managed_connection(connection_id, connection)
666                .await?;
667        }
668        Ok(())
669    }
670
671    // =========================================================================
672    // FTP / FTPS
673    // =========================================================================
674
675    #[cfg(feature = "ftp")]
676    pub async fn create_ftp_connection(
677        &self,
678        connection_id: String,
679        config: crate::ftp_client::FtpConfig,
680    ) -> Result<()> {
681        let client = FtpClient::connect(&config).await?;
682        self.replace_managed_connection(
683            connection_id,
684            ManagedConnection::Ftp(Arc::new(RwLock::new(client))),
685        )
686        .await
687    }
688
689    #[cfg(feature = "ftp")]
690    pub async fn close_ftp_connection(&self, connection_id: &str) -> Result<()> {
691        if let Some(connection) = self
692            .take_connection_if_kind(connection_id, ConnectionSlotKind::Ftp)
693            .await?
694        {
695            self.disconnect_managed_connection(connection_id, connection)
696                .await?;
697        }
698        Ok(())
699    }
700
701    // =========================================================================
702    // Remote desktop (RDP / VNC)
703    // =========================================================================
704
705    #[cfg(feature = "desktop")]
706    pub async fn create_desktop_connection(
707        &self,
708        connection_id: String,
709        request: &DesktopConnectRequest,
710    ) -> Result<(u16, u16)> {
711        use crate::desktop_protocol::DesktopKind;
712        let (kind, client): (ProtocolKind, Box<dyn DesktopProtocol>) = match request.protocol {
713            DesktopKind::Rdp => {
714                let config = request.to_rdp_config();
715                (
716                    ProtocolKind::Rdp,
717                    Box::new(RdpClient::connect(&config).await?),
718                )
719            }
720            DesktopKind::Vnc => {
721                let config = request.to_vnc_config();
722                (
723                    ProtocolKind::Vnc,
724                    Box::new(VncClient::connect(&config).await?),
725                )
726            }
727        };
728
729        let (w, h) = client.desktop_size();
730
731        self.replace_managed_connection(
732            connection_id,
733            ManagedConnection::Desktop {
734                kind,
735                client: Arc::new(RwLock::new(client)),
736            },
737        )
738        .await?;
739
740        Ok((w, h))
741    }
742
743    #[cfg(feature = "desktop")]
744    pub async fn close_desktop_connection(&self, connection_id: &str) -> Result<()> {
745        if let Some(connection) = self
746            .take_connection_if_kind(connection_id, ConnectionSlotKind::Desktop)
747            .await?
748        {
749            self.disconnect_managed_connection(connection_id, connection)
750                .await?;
751        }
752        Ok(())
753    }
754
755    // =========================================================================
756    // Postgres
757    // =========================================================================
758
759    /// Open a Postgres pool, optionally tunneled through an SSH connection
760    /// this manager already owns. Available when both `postgres` and `ssh`
761    /// features are enabled.
762    #[cfg(all(feature = "postgres", feature = "ssh"))]
763    pub async fn create_postgres_connection(
764        &self,
765        connection_id: String,
766        mut config: PgConfig,
767        tunnel: Option<SshTunnelRef>,
768    ) -> Result<()> {
769        // The tunnel seam lives here, not in the pool: if the profile
770        // routes through SSH, this manager owns the already-open SSH
771        // connection, so it stands up the `direct-tcpip` local forward
772        // and points the pool's `PgConfig` at the loopback end. The
773        // Postgres layer dials a plain host:port and has no knowledge of
774        // SSH. Resolve the source up front so a missing one is a single
775        // typed error rather than a partial connect.
776        let tunnel_guard = if let Some(t) = tunnel {
777            let ssh_client = match self.get_connection(&t.ssh_connection_id).await {
778                Some(c) => c,
779                None => {
780                    return Err(anyhow::Error::from(
781                        ssh_commander_pg::PgError::TunnelSourceMissing(format!(
782                            "ssh connection '{}' is not registered or has been closed",
783                            t.ssh_connection_id
784                        )),
785                    ));
786                }
787            };
788            let opened = SshTunnel::open(ssh_client, t.remote_host.clone(), t.remote_port)
789                .await
790                .map_err(|e| {
791                    anyhow::Error::from(ssh_commander_pg::PgError::Tunnel(e.to_string()))
792                })?;
793            // Redirect the pool at the local end of the forward.
794            config.host = "127.0.0.1".to_string();
795            config.port = opened.local_port();
796            Some(Arc::new(opened))
797        } else {
798            None
799        };
800
801        let cancel_token = self.register_pending_connection(&connection_id).await;
802        let connect_result = tokio::select! {
803            res = PgPool::connect(config) => res.map_err(anyhow::Error::from),
804            _ = cancel_token.cancelled() => Err(anyhow::anyhow!("Connection cancelled by user")),
805        };
806        self.clear_pending_connection(&connection_id).await;
807
808        let pool = connect_result?;
809        self.replace_managed_connection(
810            connection_id,
811            ManagedConnection::Postgres {
812                pool,
813                tunnel: tunnel_guard,
814            },
815        )
816        .await
817    }
818
819    /// Open a Postgres pool. This build has no SSH feature, so there is no
820    /// tunnel option — the pool connects directly to `config`'s host:port.
821    #[cfg(all(feature = "postgres", not(feature = "ssh")))]
822    pub async fn create_postgres_connection(
823        &self,
824        connection_id: String,
825        config: PgConfig,
826    ) -> Result<()> {
827        let cancel_token = self.register_pending_connection(&connection_id).await;
828        let connect_result = tokio::select! {
829            res = PgPool::connect(config) => res.map_err(anyhow::Error::from),
830            _ = cancel_token.cancelled() => Err(anyhow::anyhow!("Connection cancelled by user")),
831        };
832        self.clear_pending_connection(&connection_id).await;
833
834        let pool = connect_result?;
835        self.replace_managed_connection(connection_id, ManagedConnection::Postgres { pool })
836            .await
837    }
838
839    #[cfg(feature = "postgres")]
840    pub async fn close_postgres_connection(&self, connection_id: &str) -> Result<()> {
841        if let Some(connection) = self
842            .take_connection_if_kind(connection_id, ConnectionSlotKind::Postgres)
843            .await?
844        {
845            self.disconnect_managed_connection(connection_id, connection)
846                .await?;
847        }
848        Ok(())
849    }
850
851    /// Start the frame update loop for a desktop connection.
852    ///
853    /// Not yet wired up to the WebSocket server — kept here so the RDP/VNC
854    /// stubs have a concrete dispatch point once the protocol clients gain
855    /// real implementations. Remove the allow once a caller appears.
856    #[cfg(feature = "desktop")]
857    #[allow(dead_code)]
858    pub async fn start_desktop_stream(
859        &self,
860        connection_id: &str,
861        frame_tx: mpsc::UnboundedSender<FrameUpdate>,
862        cancel: CancellationToken,
863    ) -> Result<()> {
864        let client = self
865            .get_desktop_connection(connection_id)
866            .await
867            .ok_or_else(|| anyhow::anyhow!("Desktop connection not found: {}", connection_id))?;
868        let client = client.read().await;
869        client.start_frame_loop(frame_tx, cancel).await
870    }
871}
872
873// =============================================================================
874// Unit tests
875// =============================================================================
876#[cfg(test)]
877mod tests {
878    use super::*;
879    #[cfg(all(feature = "desktop", feature = "ssh"))]
880    use async_trait::async_trait;
881
882    #[cfg(all(feature = "desktop", feature = "ssh"))]
883    struct TestDesktopClient;
884
885    #[cfg(all(feature = "desktop", feature = "ssh"))]
886    #[async_trait]
887    impl DesktopProtocol for TestDesktopClient {
888        async fn start_frame_loop(
889            &self,
890            _frame_tx: mpsc::UnboundedSender<FrameUpdate>,
891            _cancel: CancellationToken,
892        ) -> Result<()> {
893            Ok(())
894        }
895
896        async fn send_key(&self, _key_code: u32, _down: bool) -> Result<()> {
897            Ok(())
898        }
899
900        async fn send_pointer(&self, _x: u16, _y: u16, _button_mask: u8) -> Result<()> {
901            Ok(())
902        }
903
904        async fn request_full_frame(&self) -> Result<()> {
905            Ok(())
906        }
907
908        async fn set_clipboard(&self, _text: String) -> Result<()> {
909            Ok(())
910        }
911
912        fn desktop_size(&self) -> (u16, u16) {
913            (1024, 768)
914        }
915
916        async fn resize(&mut self, _width: u16, _height: u16) -> Result<()> {
917            Ok(())
918        }
919
920        async fn disconnect(&mut self) -> Result<()> {
921            Ok(())
922        }
923    }
924
925    #[cfg(all(feature = "ssh", feature = "desktop"))]
926    fn disconnected_ssh_client() -> SshClient {
927        SshClient::new(Arc::new(HostKeyStore::new(
928            std::env::temp_dir().join("r-shell-test-known-hosts"),
929        )))
930    }
931
932    #[tokio::test]
933    async fn test_new_manager_has_no_connections() {
934        let mgr = ConnectionManager::new();
935        assert!(mgr.list_connections().await.is_empty());
936    }
937
938    #[tokio::test]
939    async fn test_connection_kind_returns_none_for_unknown() {
940        let mgr = ConnectionManager::new();
941        assert!(mgr.connection_kind("unknown-id").await.is_none());
942        assert!(mgr.get_connection_type("unknown-id").await.is_none());
943    }
944
945    #[tokio::test]
946    async fn test_cancel_nonexistent_pending_connection() {
947        let mgr = ConnectionManager::new();
948        assert!(!mgr.cancel_pending_connection("ghost").await);
949    }
950
951    #[tokio::test]
952    async fn test_protocol_kind_round_trip() {
953        #[cfg(feature = "ssh")]
954        assert_eq!(ProtocolKind::Ssh.as_str(), "SSH");
955        #[cfg(feature = "sftp")]
956        assert_eq!(ProtocolKind::Sftp.as_str(), "SFTP");
957        #[cfg(feature = "ftp")]
958        assert_eq!(ProtocolKind::Ftp.as_str(), "FTP");
959        #[cfg(feature = "desktop")]
960        assert_eq!(ProtocolKind::Rdp.as_str(), "RDP");
961        #[cfg(feature = "desktop")]
962        assert_eq!(ProtocolKind::Vnc.as_str(), "VNC");
963        #[cfg(feature = "postgres")]
964        assert_eq!(ProtocolKind::Postgres.as_str(), "POSTGRES");
965    }
966
967    #[cfg(feature = "postgres")]
968    #[tokio::test]
969    async fn test_close_postgres_of_unknown_id_is_noop() {
970        let mgr = ConnectionManager::new();
971        let result = mgr.close_postgres_connection("ghost").await;
972        assert!(result.is_ok());
973    }
974
975    #[cfg(feature = "sftp")]
976    #[tokio::test]
977    async fn test_close_sftp_of_unknown_id_is_noop() {
978        let mgr = ConnectionManager::new();
979        let result = mgr.close_sftp_connection("ghost").await;
980        assert!(result.is_ok());
981    }
982
983    #[cfg(feature = "ftp")]
984    #[tokio::test]
985    async fn test_close_ftp_of_unknown_id_is_noop() {
986        let mgr = ConnectionManager::new();
987        let result = mgr.close_ftp_connection("ghost").await;
988        assert!(result.is_ok());
989    }
990
991    #[cfg(all(feature = "ssh", feature = "desktop"))]
992    #[tokio::test]
993    async fn test_close_connection_rejects_non_ssh_without_removing_it() {
994        let mgr = ConnectionManager::new();
995        {
996            let mut connections = mgr.connections.write().await;
997            connections.insert(
998                "desktop".to_string(),
999                ManagedConnection::Desktop {
1000                    kind: ProtocolKind::Rdp,
1001                    client: Arc::new(RwLock::new(Box::new(TestDesktopClient))),
1002                },
1003            );
1004        }
1005
1006        let err = mgr
1007            .close_connection("desktop")
1008            .await
1009            .expect_err("closing an RDP connection through the SSH API must fail");
1010        assert!(err.to_string().contains("not SSH"));
1011        assert_eq!(
1012            mgr.connection_kind("desktop").await,
1013            Some(ProtocolKind::Rdp)
1014        );
1015    }
1016
1017    #[cfg(all(feature = "ssh", feature = "desktop"))]
1018    #[tokio::test]
1019    async fn test_close_desktop_connection_rejects_ssh_without_removing_it() {
1020        let mgr = ConnectionManager::new();
1021        {
1022            let mut connections = mgr.connections.write().await;
1023            connections.insert(
1024                "ssh".to_string(),
1025                ManagedConnection::Ssh(Arc::new(RwLock::new(disconnected_ssh_client()))),
1026            );
1027        }
1028
1029        let err = mgr
1030            .close_desktop_connection("ssh")
1031            .await
1032            .expect_err("closing an SSH connection through the desktop API must fail");
1033        assert!(err.to_string().contains("not desktop"));
1034        assert_eq!(mgr.connection_kind("ssh").await, Some(ProtocolKind::Ssh));
1035    }
1036}