Skip to main content

toride_ssh_forward/
control.rs

1//! Port forwarding control via `ControlMaster` sessions.
2
3#[cfg(unix)]
4use std::os::unix::fs::FileTypeExt;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use toride_ssh_core::{Error, Result};
10
11const MAX_CONTROL_SOCKET_CANDIDATE_SIZE: u64 = 1024;
12
13/// Whether a forward is local (-L), remote (-R), or dynamic/SOCKS (-D).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum ForwardType {
16    Local,
17    Remote,
18    Dynamic,
19}
20
21impl std::fmt::Display for ForwardType {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::Local => write!(f, "local"),
25            Self::Remote => write!(f, "remote"),
26            Self::Dynamic => write!(f, "dynamic"),
27        }
28    }
29}
30
31/// A single active port forward on a `ControlMaster` session.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PortForward {
34    /// Local bind address (e.g. `127.0.0.1` or `*` under `GatewayPorts`).
35    pub local_addr: String,
36    pub local_port: u16,
37    pub remote_addr: String,
38    pub remote_port: u16,
39    pub forward_type: ForwardType,
40}
41
42/// A discovered `ControlMaster` session.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ControlSession {
45    pub control_path: PathBuf,
46    pub host: String,
47    pub pid: Option<u32>,
48    pub established: Option<std::time::SystemTime>,
49}
50
51async fn ssh_control_cmd(control_path: &Path, action: &str) -> Result<String> {
52    let path_str = control_path
53        .to_str()
54        .ok_or_else(|| Error::ForwardFailed("control path is not valid UTF-8".into()))?;
55
56    let action = action.to_owned();
57    let path = path_str.to_owned();
58
59    tokio::task::spawn_blocking(move || {
60        duct::cmd("ssh", ["-O", &action, "-S", &path, "-x", "nohost"])
61            .stderr_to_stdout()
62            .read()
63            .map_err(|e| Error::CommandFailed(format!("ssh -O {action}: {e}")))
64    })
65    .await
66    .map_err(|e| Error::TaskFailed(e.to_string()))?
67}
68
69async fn check_alive(control_path: &Path) -> bool {
70    ssh_control_cmd(control_path, "check").await.is_ok()
71}
72
73/// List active port forwards on a `ControlMaster` session.
74///
75/// # Errors
76/// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`]
77/// (ssh fails), or [`Error::TaskFailed`] (spawn failure).
78pub async fn list_forwards(control_path: &Path) -> Result<Vec<PortForward>> {
79    let output = ssh_control_cmd(control_path, "list").await?;
80    Ok(parse_forward_output(&output))
81}
82
83// Boxing works around the higher-ranked `Send` inference limitation
84// ("Send is not general enough", rust-lang/rust#110338).
85type BoxedFanoutFuture<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
86
87async fn join_all_bounded<I, T>(limit: usize, futs: I) -> Vec<T>
88where
89    I: IntoIterator,
90    I::Item: std::future::Future<Output = T>,
91{
92    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(limit));
93    let guarded = futs.into_iter().map(|fut| {
94        let semaphore = std::sync::Arc::clone(&semaphore);
95        async move {
96            let _permit = semaphore.acquire_owned().await.ok();
97            fut.await
98        }
99    });
100    futures::future::join_all(guarded).await
101}
102
103/// Run [`list_forwards`] for many control paths with bounded `ssh -O list`
104/// spawns; results in input order, one failure never cancels the others.
105pub async fn list_forwards_bounded<I>(paths: I) -> Vec<Result<Vec<PortForward>>>
106where
107    I: IntoIterator<Item = PathBuf>,
108{
109    let futs: Vec<BoxedFanoutFuture<Result<Vec<PortForward>>>> = paths
110        .into_iter()
111        .map(|path| {
112            let fut: BoxedFanoutFuture<_> = Box::pin(async move { list_forwards(&path).await });
113            fut
114        })
115        .collect();
116    join_all_bounded(MAX_CONCURRENT_CONTROL_CMDS, futs).await
117}
118
119pub(crate) fn parse_forward_output(output: &str) -> Vec<PortForward> {
120    let mut forwards = Vec::new();
121    let mut current_type: Option<ForwardType> = None;
122
123    for line in output.lines() {
124        let trimmed = line.trim();
125
126        if trimmed.starts_with("Local connections") {
127            current_type = Some(ForwardType::Local);
128            continue;
129        }
130        if trimmed.starts_with("Remote connections") {
131            current_type = Some(ForwardType::Remote);
132            continue;
133        }
134        if trimmed.starts_with("Dynamic connections") {
135            current_type = Some(ForwardType::Dynamic);
136            continue;
137        }
138
139        let Some(ft) = current_type else {
140            continue;
141        };
142
143        if let Some(fwd) = parse_forward_line(trimmed, ft) {
144            forwards.push(fwd);
145        }
146    }
147
148    forwards
149}
150
151pub(crate) fn parse_forward_line(line: &str, forward_type: ForwardType) -> Option<PortForward> {
152    let line = line.trim_start();
153
154    let port_idx = line.find(" port ")?;
155    let local_addr = line[..port_idx].trim().trim_end_matches('.').to_owned();
156
157    let rest = &line[port_idx + 6..];
158
159    if forward_type == ForwardType::Dynamic {
160        let local_port: u16 = rest.trim().parse().ok()?;
161        return Some(PortForward {
162            local_addr,
163            local_port,
164            remote_addr: String::new(),
165            remote_port: 0,
166            forward_type,
167        });
168    }
169
170    let comma_idx = rest.find(',')?;
171    let local_port: u16 = rest[..comma_idx].trim().parse().ok()?;
172
173    let fwd_rest = &rest[comma_idx + 1..];
174    let fwd_label = "forwarding to ";
175    let fwd_idx = fwd_rest.find(fwd_label)?;
176    let rhost_port = &fwd_rest[fwd_idx + fwd_label.len()..];
177
178    let rport_idx = rhost_port.rfind(" port ")?;
179    let remote_addr = rhost_port[..rport_idx].trim().to_owned();
180    let remote_port: u16 = rhost_port[rport_idx + 6..].trim().parse().ok()?;
181
182    Some(PortForward {
183        local_addr,
184        local_port,
185        remote_addr,
186        remote_port,
187        forward_type,
188    })
189}
190
191/// Cancel the forward on `local_port` (lists first to find its spec);
192/// an inherent TOCTOU race means it may vanish before the cancel lands.
193///
194/// # Errors
195/// [`Error::ForwardNotFound`] if no forward is listening on `local_port`;
196/// otherwise as [`list_forwards`] / [`cancel_known_forward`].
197pub async fn cancel_forward(control_path: &Path, local_port: u16) -> Result<()> {
198    let forwards = list_forwards(control_path).await?;
199
200    let forward = forwards
201        .iter()
202        .find(|f| f.local_port == local_port)
203        .ok_or_else(|| {
204            Error::ForwardNotFound(format!("no forward found on local port {local_port}"))
205        })?;
206
207    cancel_known_forward(control_path, forward).await
208}
209
210/// Cancel a known forward directly, skipping the list round-trip; the spec
211/// is `[addr]:lport[:rhost:rport]`, with an empty rhost sent as `localhost`.
212///
213/// # Errors
214/// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`]
215/// (ssh fails), or [`Error::TaskFailed`] (spawn failure).
216pub async fn cancel_known_forward(control_path: &Path, forward: &PortForward) -> Result<()> {
217    let path_str = control_path
218        .to_str()
219        .ok_or_else(|| Error::ForwardFailed("control path is not valid UTF-8".into()))?;
220
221    let flag = match forward.forward_type {
222        ForwardType::Local => "-L",
223        ForwardType::Remote => "-R",
224        ForwardType::Dynamic => "-D",
225    };
226
227    let spec = if forward.forward_type == ForwardType::Dynamic {
228        format!("[{}]:{}", forward.local_addr, forward.local_port)
229    } else {
230        format!(
231            "[{}]:{}:{}:{}",
232            forward.local_addr,
233            forward.local_port,
234            if forward.remote_addr.is_empty() {
235                "localhost"
236            } else {
237                &forward.remote_addr
238            },
239            forward.remote_port
240        )
241    };
242
243    let path_owned = path_str.to_owned();
244
245    tokio::task::spawn_blocking(move || {
246        duct::cmd(
247            "ssh",
248            [
249                flag,
250                spec.as_str(),
251                "-O",
252                "cancel",
253                "-S",
254                &path_owned,
255                "-x",
256                "nohost",
257            ],
258        )
259        .run()
260        .map_err(|e| Error::CommandFailed(format!("ssh -O cancel: {e}")))
261    })
262    .await
263    .map_err(|e| Error::TaskFailed(e.to_string()))??;
264
265    Ok(())
266}
267
268/// Close a `ControlMaster` session (`ssh -O exit`); the socket file is
269/// unlinked asynchronously, so re-check the path before assuming removal.
270///
271/// # Errors
272/// [`Error::ForwardFailed`] (non-UTF-8 path), [`Error::CommandFailed`]
273/// (ssh fails), or [`Error::TaskFailed`] (spawn failure).
274pub async fn exit_session(control_path: &Path) -> Result<()> {
275    let path = control_path.to_path_buf();
276
277    tokio::task::spawn_blocking(move || {
278        let path_str = path
279            .to_str()
280            .ok_or_else(|| Error::ForwardFailed("control path is not valid UTF-8".into()))?;
281
282        let result = duct::cmd("ssh", ["-O", "exit", "-S", path_str])
283            .run()
284            .map_err(|e| Error::CommandFailed(format!("ssh -O exit: {e}")));
285
286        if result.is_ok() || is_stale_socket(&path) {
287            let _ = std::fs::remove_file(&path);
288        }
289
290        result
291    })
292    .await
293    .map_err(|e| Error::TaskFailed(e.to_string()))??;
294
295    Ok(())
296}
297
298fn is_stale_socket(path: &Path) -> bool {
299    match std::fs::metadata(path) {
300        Ok(meta) => {
301            #[cfg(unix)]
302            if meta.file_type().is_socket() {
303                return true;
304            }
305            meta.is_file() && meta.len() == 0
306        }
307        Err(_) => false,
308    }
309}
310
311const MAX_CONCURRENT_CONTROL_CMDS: usize = 8;
312
313/// Discover live `ControlMaster` sessions by scanning `ssh_dir` prefixes
314/// (`cm-`, `control-`, `mux-`, `ctrl-`) and `/tmp/ssh-*` via `ssh -O check`.
315///
316/// # Errors
317/// [`Error::TaskFailed`] if the directory scan task fails; individual
318/// `ssh -O check` failures only exclude the candidate.
319pub async fn list_sessions(ssh_dir: &Path) -> Result<Vec<ControlSession>> {
320    let ssh_dir = ssh_dir.to_path_buf();
321    let tmp_dir = std::path::PathBuf::from("/tmp");
322
323    let candidates = tokio::task::spawn_blocking(move || {
324        let mut candidates = collect_matching_any(&ssh_dir, SSH_DIR_PREFIXES);
325
326        candidates.extend(collect_matching_any(&tmp_dir, &["ssh-"]));
327
328        candidates.sort();
329        candidates.dedup();
330
331        candidates
332    })
333    .await
334    .map_err(|e| Error::TaskFailed(e.to_string()))?;
335
336    let checks: Vec<BoxedFanoutFuture<bool>> = candidates
337        .iter()
338        .map(|candidate| {
339            let candidate = candidate.clone();
340            let fut: BoxedFanoutFuture<bool> =
341                Box::pin(async move { check_alive(&candidate).await });
342            fut
343        })
344        .collect();
345    let alive_flags = join_all_bounded(MAX_CONCURRENT_CONTROL_CMDS, checks).await;
346
347    let alive = candidates
348        .into_iter()
349        .zip(alive_flags)
350        .filter_map(|(candidate, alive)| alive.then(|| build_session(&candidate)))
351        .collect();
352
353    Ok(alive)
354}
355
356const SSH_DIR_PREFIXES: &[&str] = &["cm-", "control-", "mux-", "ctrl-"];
357
358fn collect_matching_any(dir: &Path, prefixes: &[&str]) -> Vec<PathBuf> {
359    let Ok(entries) = std::fs::read_dir(dir) else {
360        return Vec::new();
361    };
362    let mut out = Vec::new();
363    for entry in entries.flatten() {
364        let path = entry.path();
365        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
366            continue;
367        };
368        if prefixes.iter().any(|p| name.starts_with(p)) && is_socket_or_candidate(&path) {
369            out.push(path);
370        }
371    }
372    out
373}
374
375fn is_socket_or_candidate(path: &Path) -> bool {
376    match std::fs::metadata(path) {
377        Ok(meta) => {
378            let ft = meta.file_type();
379            #[cfg(unix)]
380            if ft.is_socket() {
381                return true;
382            }
383            if ft.is_file() && meta.len() < MAX_CONTROL_SOCKET_CANDIDATE_SIZE {
384                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
385                return !name.contains('.');
386            }
387            false
388        }
389        Err(_) => false,
390    }
391}
392
393fn build_session(control_path: &Path) -> ControlSession {
394    let name = control_path
395        .file_name()
396        .and_then(|n| n.to_str())
397        .unwrap_or("unknown");
398
399    let host = extract_host_from_name(name);
400
401    let pid = extract_pid_from_name(name);
402
403    let established = control_path.metadata().ok().and_then(|m| m.modified().ok());
404
405    ControlSession {
406        control_path: control_path.to_path_buf(),
407        host,
408        pid,
409        established,
410    }
411}
412
413pub(crate) fn extract_host_from_name(name: &str) -> String {
414    let rest = name
415        .strip_prefix("cm-")
416        .or_else(|| name.strip_prefix("control-"))
417        .or_else(|| name.strip_prefix("mux-"))
418        .or_else(|| name.strip_prefix("ctrl-"))
419        .or_else(|| name.strip_prefix("ssh-"))
420        .unwrap_or(name);
421
422    if let Some(at_idx) = rest.find('@') {
423        let after_at = &rest[at_idx + 1..];
424        if after_at.starts_with('[')
425            && let Some(bracket_end) = after_at.find(']')
426        {
427            return after_at[1..bracket_end].to_owned();
428        }
429        if after_at.matches(':').count() >= 2 {
430            if let Some(host) = split_bare_ipv6_host_port(after_at) {
431                return host.to_owned();
432            }
433            return after_at.to_owned();
434        }
435        if let Some(colon_idx) = after_at.find(':') {
436            return after_at[..colon_idx].to_owned();
437        }
438        return after_at.to_owned();
439    }
440
441    rest.to_owned()
442}
443
444fn split_bare_ipv6_host_port(s: &str) -> Option<&str> {
445    let colon_count = s.matches(':').count();
446    if colon_count < 3 || !s.contains("::") {
447        return None;
448    }
449    let last_colon = s.rfind(':')?;
450    let port_part = &s[last_colon + 1..];
451    if port_part.is_empty() || !port_part.bytes().all(|b| b.is_ascii_digit()) {
452        return None;
453    }
454    let host_part = &s[..last_colon];
455    if host_part.is_empty() {
456        return None;
457    }
458    Some(host_part)
459}
460
461pub(crate) fn extract_pid_from_name(name: &str) -> Option<u32> {
462    let (_prefix, pid_str) = name.rsplit_once('-')?;
463    let pid: u32 = pid_str.parse().ok()?;
464    (pid > 0).then_some(pid)
465}
466
467#[cfg(test)]
468#[path = "control.test.rs"]
469mod tests;