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;
15#[cfg(feature = "postgres")]
16use ssh_commander_pg::{PgConfig, PgPool};
17use anyhow::Result;
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#[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
64pub 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, client: Arc<RwLock<Box<dyn DesktopProtocol>>>,
80 },
81 #[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)] enum 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
177pub struct ConnectionManager {
182 connections: Arc<RwLock<HashMap<String, ManagedConnection>>>,
183 #[cfg(feature = "ssh")]
186 pty_sessions: Arc<RwLock<HashMap<String, Arc<PtySession>>>>,
187 #[cfg(feature = "ssh")]
190 pty_generations: Arc<RwLock<HashMap<String, u64>>>,
191 pending_connections: Arc<RwLock<HashMap<String, CancellationToken>>>,
192 #[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 #[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 #[cfg(feature = "ssh")]
234 pub fn host_keys(&self) -> Arc<HostKeyStore> {
235 self.host_keys.clone()
236 }
237
238 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 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 #[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 #[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 #[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 }
399 }
400 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 #[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 #[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 {
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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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| anyhow::Error::from(ssh_commander_pg::PgError::Tunnel(e.to_string())))?;
791 config.host = "127.0.0.1".to_string();
793 config.port = opened.local_port();
794 Some(Arc::new(opened))
795 } else {
796 None
797 };
798
799 let cancel_token = self.register_pending_connection(&connection_id).await;
800 let connect_result = tokio::select! {
801 res = PgPool::connect(config) => res.map_err(anyhow::Error::from),
802 _ = cancel_token.cancelled() => Err(anyhow::anyhow!("Connection cancelled by user")),
803 };
804 self.clear_pending_connection(&connection_id).await;
805
806 let pool = connect_result?;
807 self.replace_managed_connection(
808 connection_id,
809 ManagedConnection::Postgres {
810 pool,
811 tunnel: tunnel_guard,
812 },
813 )
814 .await
815 }
816
817 #[cfg(all(feature = "postgres", not(feature = "ssh")))]
820 pub async fn create_postgres_connection(
821 &self,
822 connection_id: String,
823 config: PgConfig,
824 ) -> Result<()> {
825 let cancel_token = self.register_pending_connection(&connection_id).await;
826 let connect_result = tokio::select! {
827 res = PgPool::connect(config) => res.map_err(anyhow::Error::from),
828 _ = cancel_token.cancelled() => Err(anyhow::anyhow!("Connection cancelled by user")),
829 };
830 self.clear_pending_connection(&connection_id).await;
831
832 let pool = connect_result?;
833 self.replace_managed_connection(connection_id, ManagedConnection::Postgres { pool })
834 .await
835 }
836
837 #[cfg(feature = "postgres")]
838 pub async fn close_postgres_connection(&self, connection_id: &str) -> Result<()> {
839 if let Some(connection) = self
840 .take_connection_if_kind(connection_id, ConnectionSlotKind::Postgres)
841 .await?
842 {
843 self.disconnect_managed_connection(connection_id, connection)
844 .await?;
845 }
846 Ok(())
847 }
848
849 #[cfg(feature = "desktop")]
855 #[allow(dead_code)]
856 pub async fn start_desktop_stream(
857 &self,
858 connection_id: &str,
859 frame_tx: mpsc::UnboundedSender<FrameUpdate>,
860 cancel: CancellationToken,
861 ) -> Result<()> {
862 let client = self
863 .get_desktop_connection(connection_id)
864 .await
865 .ok_or_else(|| anyhow::anyhow!("Desktop connection not found: {}", connection_id))?;
866 let client = client.read().await;
867 client.start_frame_loop(frame_tx, cancel).await
868 }
869}
870
871#[cfg(test)]
875mod tests {
876 use super::*;
877 #[cfg(all(feature = "desktop", feature = "ssh"))]
878 use async_trait::async_trait;
879
880 #[cfg(all(feature = "desktop", feature = "ssh"))]
881 struct TestDesktopClient;
882
883 #[cfg(all(feature = "desktop", feature = "ssh"))]
884 #[async_trait]
885 impl DesktopProtocol for TestDesktopClient {
886 async fn start_frame_loop(
887 &self,
888 _frame_tx: mpsc::UnboundedSender<FrameUpdate>,
889 _cancel: CancellationToken,
890 ) -> Result<()> {
891 Ok(())
892 }
893
894 async fn send_key(&self, _key_code: u32, _down: bool) -> Result<()> {
895 Ok(())
896 }
897
898 async fn send_pointer(&self, _x: u16, _y: u16, _button_mask: u8) -> Result<()> {
899 Ok(())
900 }
901
902 async fn request_full_frame(&self) -> Result<()> {
903 Ok(())
904 }
905
906 async fn set_clipboard(&self, _text: String) -> Result<()> {
907 Ok(())
908 }
909
910 fn desktop_size(&self) -> (u16, u16) {
911 (1024, 768)
912 }
913
914 async fn resize(&mut self, _width: u16, _height: u16) -> Result<()> {
915 Ok(())
916 }
917
918 async fn disconnect(&mut self) -> Result<()> {
919 Ok(())
920 }
921 }
922
923 #[cfg(all(feature = "ssh", feature = "desktop"))]
924 fn disconnected_ssh_client() -> SshClient {
925 SshClient::new(Arc::new(HostKeyStore::new(
926 std::env::temp_dir().join("r-shell-test-known-hosts"),
927 )))
928 }
929
930 #[tokio::test]
931 async fn test_new_manager_has_no_connections() {
932 let mgr = ConnectionManager::new();
933 assert!(mgr.list_connections().await.is_empty());
934 }
935
936 #[tokio::test]
937 async fn test_connection_kind_returns_none_for_unknown() {
938 let mgr = ConnectionManager::new();
939 assert!(mgr.connection_kind("unknown-id").await.is_none());
940 assert!(mgr.get_connection_type("unknown-id").await.is_none());
941 }
942
943 #[tokio::test]
944 async fn test_cancel_nonexistent_pending_connection() {
945 let mgr = ConnectionManager::new();
946 assert!(!mgr.cancel_pending_connection("ghost").await);
947 }
948
949 #[tokio::test]
950 async fn test_protocol_kind_round_trip() {
951 #[cfg(feature = "ssh")]
952 assert_eq!(ProtocolKind::Ssh.as_str(), "SSH");
953 #[cfg(feature = "sftp")]
954 assert_eq!(ProtocolKind::Sftp.as_str(), "SFTP");
955 #[cfg(feature = "ftp")]
956 assert_eq!(ProtocolKind::Ftp.as_str(), "FTP");
957 #[cfg(feature = "desktop")]
958 assert_eq!(ProtocolKind::Rdp.as_str(), "RDP");
959 #[cfg(feature = "desktop")]
960 assert_eq!(ProtocolKind::Vnc.as_str(), "VNC");
961 #[cfg(feature = "postgres")]
962 assert_eq!(ProtocolKind::Postgres.as_str(), "POSTGRES");
963 }
964
965 #[cfg(feature = "postgres")]
966 #[tokio::test]
967 async fn test_close_postgres_of_unknown_id_is_noop() {
968 let mgr = ConnectionManager::new();
969 let result = mgr.close_postgres_connection("ghost").await;
970 assert!(result.is_ok());
971 }
972
973 #[cfg(feature = "sftp")]
974 #[tokio::test]
975 async fn test_close_sftp_of_unknown_id_is_noop() {
976 let mgr = ConnectionManager::new();
977 let result = mgr.close_sftp_connection("ghost").await;
978 assert!(result.is_ok());
979 }
980
981 #[cfg(feature = "ftp")]
982 #[tokio::test]
983 async fn test_close_ftp_of_unknown_id_is_noop() {
984 let mgr = ConnectionManager::new();
985 let result = mgr.close_ftp_connection("ghost").await;
986 assert!(result.is_ok());
987 }
988
989 #[cfg(all(feature = "ssh", feature = "desktop"))]
990 #[tokio::test]
991 async fn test_close_connection_rejects_non_ssh_without_removing_it() {
992 let mgr = ConnectionManager::new();
993 {
994 let mut connections = mgr.connections.write().await;
995 connections.insert(
996 "desktop".to_string(),
997 ManagedConnection::Desktop {
998 kind: ProtocolKind::Rdp,
999 client: Arc::new(RwLock::new(Box::new(TestDesktopClient))),
1000 },
1001 );
1002 }
1003
1004 let err = mgr
1005 .close_connection("desktop")
1006 .await
1007 .expect_err("closing an RDP connection through the SSH API must fail");
1008 assert!(err.to_string().contains("not SSH"));
1009 assert_eq!(
1010 mgr.connection_kind("desktop").await,
1011 Some(ProtocolKind::Rdp)
1012 );
1013 }
1014
1015 #[cfg(all(feature = "ssh", feature = "desktop"))]
1016 #[tokio::test]
1017 async fn test_close_desktop_connection_rejects_ssh_without_removing_it() {
1018 let mgr = ConnectionManager::new();
1019 {
1020 let mut connections = mgr.connections.write().await;
1021 connections.insert(
1022 "ssh".to_string(),
1023 ManagedConnection::Ssh(Arc::new(RwLock::new(disconnected_ssh_client()))),
1024 );
1025 }
1026
1027 let err = mgr
1028 .close_desktop_connection("ssh")
1029 .await
1030 .expect_err("closing an SSH connection through the desktop API must fail");
1031 assert!(err.to_string().contains("not desktop"));
1032 assert_eq!(mgr.connection_kind("ssh").await, Some(ProtocolKind::Ssh));
1033 }
1034}