toride_ssh_agent/
session.rs1use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use toride_ssh_core::Result;
7
8#[derive(Debug, Clone)]
10pub struct ControlSession {
11 pub control_path: PathBuf,
13 pub host: String,
15 pub established: SystemTime,
17}
18
19pub 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 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 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 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#[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
148pub(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 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 let Some(colon) = stripped.find(':') {
182 return stripped[..colon].to_string();
183 }
184
185 stripped.to_string()
186}
187
188async 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 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), Ok(Err(e)) => {
213 if e.kind() == std::io::ErrorKind::NotFound {
216 tracing::warn!("ssh binary not found, cannot check control socket");
218 return None;
219 }
220 Some(false) }
222 Err(_) => Some(false), }
224}
225
226#[cfg(test)]
227#[path = "session.test.rs"]
228mod tests;