1#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PortForward {
34 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#[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
73pub 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
83type 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
103pub 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
191pub 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
210pub 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
268pub 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
313pub 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;