Skip to main content

toride_ssh_forward/
lib.rs

1//! Port forwarding management via SSH `ControlMaster` sessions:
2//! [`ForwardService`] over the low-level `control` module.
3
4pub mod control;
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use toride_ssh_core::SshPaths;
10use toride_ssh_core::{Error, Result};
11
12pub use control::{ControlSession, ForwardType, PortForward};
13
14/// Port forwarding management via `ControlMaster` sessions.
15///
16/// Obtained from `SshManager::forward()`.
17pub struct ForwardService<'a> {
18    paths: &'a SshPaths,
19}
20
21impl<'a> ForwardService<'a> {
22    #[must_use]
23    pub fn new(paths: &'a SshPaths) -> Self {
24        Self { paths }
25    }
26
27    /// List `(session, forwards)` pairs in session order; a session whose
28    /// listing fails is included with an empty list (logged, not propagated).
29    ///
30    /// # Errors
31    /// As [`Self::list_sessions`].
32    pub async fn list(&self) -> Result<Vec<(ControlSession, Vec<PortForward>)>> {
33        let sessions = self.list_sessions().await?;
34        let listings =
35            control::list_forwards_bounded(sessions.iter().map(|s| s.control_path.clone())).await;
36        Ok(sessions
37            .into_iter()
38            .zip(listings)
39            .map(|(session, result)| match result {
40                Ok(forwards) => (session, forwards),
41                Err(e) => {
42                    tracing::warn!(
43                        "failed to list forwards for {}: {e}",
44                        session.control_path.display()
45                    );
46                    (session, Vec::new())
47                }
48            })
49            .collect())
50    }
51
52    /// Discover active `ControlMaster` sessions, verified via `ssh -O check`.
53    ///
54    /// # Errors
55    /// [`Error::TaskFailed`] if the directory scan task fails.
56    pub async fn list_sessions(&self) -> Result<Vec<ControlSession>> {
57        control::list_sessions(self.paths.ssh_dir()).await
58    }
59
60    /// Cancel the forward on `local_port`.
61    ///
62    /// # Errors
63    /// [`Error::ForwardNotFound`], [`Error::ForwardFailed`] (non-UTF-8
64    /// path), [`Error::CommandFailed`], or [`Error::TaskFailed`].
65    pub async fn cancel(&self, control_path: &Path, local_port: u16) -> Result<()> {
66        control::cancel_forward(control_path, local_port).await
67    }
68
69    /// List forwards for the session at `control_path`.
70    ///
71    /// # Errors
72    /// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`],
73    /// or [`Error::TaskFailed`].
74    pub async fn list_forwards(&self, control_path: &Path) -> Result<Vec<PortForward>> {
75        control::list_forwards(control_path).await
76    }
77
78    /// Cancel a known forward, skipping the list round-trip.
79    ///
80    /// # Errors
81    /// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`],
82    /// or [`Error::TaskFailed`].
83    pub async fn cancel_known(&self, control_path: &Path, forward: &PortForward) -> Result<()> {
84        control::cancel_known_forward(control_path, forward).await
85    }
86
87    /// Gracefully shut down a `ControlMaster` session (`ssh -O exit`).
88    ///
89    /// # Errors
90    /// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`],
91    /// or [`Error::TaskFailed`].
92    pub async fn exit_session(&self, control_path: &Path) -> Result<()> {
93        control::exit_session(control_path).await
94    }
95
96    /// Map each local port claimed by more than one session to the control
97    /// socket paths claiming it.
98    ///
99    /// # Errors
100    /// As [`Self::list_sessions`].
101    pub async fn conflicting_local_ports(&self) -> Result<HashMap<u16, Vec<std::path::PathBuf>>> {
102        let sessions = self.list_sessions().await?;
103        let listings =
104            control::list_forwards_bounded(sessions.iter().map(|s| s.control_path.clone())).await;
105        let mut port_owners: HashMap<u16, Vec<std::path::PathBuf>> = HashMap::new();
106
107        for (session, result) in sessions.iter().zip(listings) {
108            match result {
109                Ok(forwards) => {
110                    for fwd in forwards {
111                        port_owners
112                            .entry(fwd.local_port)
113                            .or_default()
114                            .push(session.control_path.clone());
115                    }
116                }
117                Err(e) => {
118                    tracing::warn!(
119                        "failed to list forwards for {}: {e}",
120                        session.control_path.display()
121                    );
122                }
123            }
124        }
125
126        port_owners.retain(|_, owners| owners.len() > 1);
127        Ok(port_owners)
128    }
129
130    const TEST_CONNECTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
131
132    /// Connect to `127.0.0.1:<local_port>` within `timeout`; proves only that
133    /// the local socket accepts — not end-to-end forwarding to the remote side.
134    ///
135    /// # Errors
136    /// [`Error::ForwardFailed`] on timeout or connect failure.
137    pub async fn test_connectivity_with_timeout(
138        &self,
139        local_port: u16,
140        timeout: std::time::Duration,
141    ) -> Result<()> {
142        let addr = format!("127.0.0.1:{local_port}");
143
144        tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr))
145            .await
146            .map_err(|_| {
147                Error::ForwardFailed(format!(
148                    "connection to {addr} timed out after {} seconds",
149                    timeout.as_secs()
150                ))
151            })?
152            .map_err(|e| Error::ForwardFailed(format!("cannot connect to {addr}: {e}")))?;
153
154        tracing::debug!("successfully connected to forwarded port {local_port}");
155        Ok(())
156    }
157
158    /// [`test_connectivity_with_timeout`](Self::test_connectivity_with_timeout)
159    /// with the default 2s timeout; local-acceptance only, not end-to-end.
160    ///
161    /// # Errors
162    /// As [`Self::test_connectivity_with_timeout`].
163    pub async fn test_connectivity(&self, local_port: u16) -> Result<()> {
164        self.test_connectivity_with_timeout(local_port, Self::TEST_CONNECTIVITY_TIMEOUT)
165            .await
166    }
167}