Skip to main content

toride_ssh_agent/
session.rs

1//! `ControlMaster` session management.
2
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use toride_ssh_core::Result;
7
8/// A single active SSH `ControlMaster` session.
9#[derive(Debug, Clone)]
10pub struct ControlSession {
11    /// Path to the control socket.
12    pub control_path: PathBuf,
13    /// Host alias or `[user@]host` from the session.
14    pub host: String,
15    /// When the control socket was created (file modification time).
16    pub established: SystemTime,
17}
18
19/// List active `ControlMaster` sessions by scanning for control sockets.
20///
21/// Scans the SSH directory (and any `ControlPath` pattern locations) for Unix
22/// domain socket files that resemble OpenSSH `ControlMaster` sockets, then
23/// verifies each is still alive using `ssh -O check`.
24///
25/// # Common socket patterns
26///
27/// OpenSSH places control sockets at the path specified by `ControlPath` in
28/// `~/.ssh/config`. The default location varies by distro but common patterns
29/// include:
30///
31/// - `~/.ssh/cm-%r@%h:%p`
32/// - `~/.ssh/ctrl-%C`
33/// - `/tmp/ssh-%r@%h:%p-*`
34///
35/// This function scans `ssh_dir` and `/tmp` for files that look like control
36/// sockets.
37pub async fn list_sessions(ssh_dir: &Path) -> Result<Vec<ControlSession>> {
38    let mut sessions = Vec::new();
39    let mut candidates = Vec::new();
40
41    if ssh_dir.exists() {
42        let mut entries = tokio::fs::read_dir(ssh_dir).await?;
43        while let Some(entry) = entries.next_entry().await? {
44            let path = entry.path();
45            if is_control_socket_candidate(&path).await {
46                candidates.push(path);
47            }
48        }
49    }
50
51    let tmp_dir = std::env::temp_dir();
52    if let Ok(mut entries) = tokio::fs::read_dir(&tmp_dir).await {
53        while let Some(entry) = entries.next_entry().await? {
54            let path = entry.path();
55            let name = path
56                .file_name()
57                .and_then(|n| n.to_str())
58                .unwrap_or_default();
59            if name.starts_with("ssh-") && is_control_socket_candidate(&path).await {
60                candidates.push(path);
61            }
62        }
63    }
64
65    for socket_path in candidates {
66        let host = extract_host_from_socket_path(&socket_path);
67
68        match verify_control_session(&socket_path).await {
69            Some(true) => {
70                // The socket may have disappeared between the check above and now
71                // (race with another process cleaning it up). Treat a missing file
72                // as "session gone" rather than a fatal error.
73                let Ok(metadata) = tokio::fs::metadata(&socket_path).await else {
74                    continue;
75                };
76                let established = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
77
78                sessions.push(ControlSession {
79                    control_path: socket_path,
80                    host,
81                    established,
82                });
83            }
84            Some(false) => {
85                // Dead socket — clean it up.
86                if let Err(e) = tokio::fs::remove_file(&socket_path).await {
87                    tracing::debug!(
88                        "failed to remove dead socket {}: {e}",
89                        socket_path.display()
90                    );
91                }
92            }
93            None => {
94                // ssh binary not found — cannot determine status, skip cleanup
95                // to avoid destroying active sessions.
96                tracing::debug!(
97                    "skipping socket {}: ssh binary not available",
98                    socket_path.display()
99                );
100            }
101        }
102    }
103
104    sessions.sort_by_key(|s| std::cmp::Reverse(s.established));
105
106    Ok(sessions)
107}
108
109/// Check if a file looks like an SSH control socket.
110///
111/// On Unix, control sockets are Unix domain sockets. We check the file type
112/// heuristically by looking at the name pattern first, then verifying it's
113/// a socket.
114// The non-Unix arm has no await points but the signature must stay `async`
115// for call-site parity with the Unix arm.
116#[cfg_attr(not(unix), allow(clippy::unused_async))]
117async fn is_control_socket_candidate(path: &Path) -> bool {
118    let name = path
119        .file_name()
120        .and_then(|n| n.to_str())
121        .unwrap_or_default();
122
123    let is_match = name.starts_with("cm-")
124        || name.starts_with("ctrl-")
125        || name.starts_with("mux-")
126        || name.starts_with("ssh-")
127        || name.contains('@');
128
129    if !is_match {
130        return false;
131    }
132
133    #[cfg(unix)]
134    {
135        use std::os::unix::fs::FileTypeExt;
136        let Ok(metadata) = tokio::fs::metadata(path).await else {
137            return false;
138        };
139        metadata.file_type().is_socket()
140    }
141
142    #[cfg(not(unix))]
143    {
144        path.exists()
145    }
146}
147
148/// Try to extract a host identifier from the socket path.
149///
150/// Socket paths often contain the host, e.g.:
151/// - `cm-root@server.example.com:22` -> `root@server.example.com`
152/// - `ctrl-1234abcd` -> `1234abcd`
153/// - `ssh-user@host:22-abc` -> `user@host`
154pub(crate) fn extract_host_from_socket_path(path: &Path) -> String {
155    let name = path
156        .file_name()
157        .and_then(|n| n.to_str())
158        .unwrap_or("unknown");
159
160    let stripped = name
161        .strip_prefix("cm-")
162        .or_else(|| name.strip_prefix("control-"))
163        .or_else(|| name.strip_prefix("ctrl-"))
164        .or_else(|| name.strip_prefix("mux-"))
165        .or_else(|| name.strip_prefix("ssh-"))
166        .unwrap_or(name);
167
168    // Strip trailing port and random suffix, e.g. "root@host:22-abc" -> "root@host".
169    if let Some(at_pos) = stripped.find('@') {
170        let after_at = &stripped[at_pos..];
171        if let Some(colon) = after_at.find(':') {
172            return stripped[..at_pos + colon].to_string();
173        }
174        if let Some(dash) = after_at.find('-') {
175            return stripped[..at_pos + dash].to_string();
176        }
177        return stripped.to_string();
178    }
179
180    // If there's no `@`, try to strip `:port` from any position.
181    if let Some(colon) = stripped.find(':') {
182        return stripped[..colon].to_string();
183    }
184
185    stripped.to_string()
186}
187
188/// Verify a control socket is still active using `ssh -O check`.
189///
190/// Returns `true` if `ssh -O check` exits with code 0 (master running).
191/// Returns `None` if the `ssh` binary itself cannot be executed (not in PATH),
192/// which is different from "master not running".
193async fn verify_control_session(socket_path: &Path) -> Option<bool> {
194    let path_str = match socket_path.to_str() {
195        Some(s) => s.to_owned(),
196        None => return Some(false),
197    };
198
199    // We need a dummy host argument for ssh -O check.  The host can be
200    // anything since the -S option specifies the control socket path.
201    // Using "localhost" as a placeholder.
202    let result = tokio::task::spawn_blocking(move || {
203        duct::cmd("ssh", ["-S", &path_str, "-O", "check", "localhost"])
204            .stdout_null()
205            .stderr_null()
206            .run()
207    })
208    .await;
209
210    match result {
211        Ok(Ok(_)) => Some(true), // exit code 0 = master running
212        Ok(Err(e)) => {
213            // Distinguish between "ssh not found" and "master not running".
214            // duct returns io::Error; when the binary is not found, kind is NotFound.
215            if e.kind() == std::io::ErrorKind::NotFound {
216                // ssh binary not in PATH — cannot determine status
217                tracing::warn!("ssh binary not found, cannot check control socket");
218                return None;
219            }
220            Some(false) // non-zero exit = master not running
221        }
222        Err(_) => Some(false), // task panic = treat as dead
223    }
224}
225
226#[cfg(test)]
227#[path = "session.test.rs"]
228mod tests;