use std::path::{Path, PathBuf};
use std::time::SystemTime;
use toride_ssh_core::Result;
#[derive(Debug, Clone)]
pub struct ControlSession {
pub control_path: PathBuf,
pub host: String,
pub established: SystemTime,
}
pub async fn list_sessions(ssh_dir: &Path) -> Result<Vec<ControlSession>> {
let mut sessions = Vec::new();
let mut candidates = Vec::new();
if ssh_dir.exists() {
let mut entries = tokio::fs::read_dir(ssh_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if is_control_socket_candidate(&path).await {
candidates.push(path);
}
}
}
let tmp_dir = std::env::temp_dir();
if let Ok(mut entries) = tokio::fs::read_dir(&tmp_dir).await {
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if name.starts_with("ssh-") && is_control_socket_candidate(&path).await {
candidates.push(path);
}
}
}
for socket_path in candidates {
let host = extract_host_from_socket_path(&socket_path);
match verify_control_session(&socket_path).await {
Some(true) => {
let Ok(metadata) = tokio::fs::metadata(&socket_path).await else {
continue;
};
let established = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
sessions.push(ControlSession {
control_path: socket_path,
host,
established,
});
}
Some(false) => {
if let Err(e) = tokio::fs::remove_file(&socket_path).await {
tracing::debug!(
"failed to remove dead socket {}: {e}",
socket_path.display()
);
}
}
None => {
tracing::debug!(
"skipping socket {}: ssh binary not available",
socket_path.display()
);
}
}
}
sessions.sort_by_key(|s| std::cmp::Reverse(s.established));
Ok(sessions)
}
#[cfg_attr(not(unix), allow(clippy::unused_async))]
async fn is_control_socket_candidate(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let is_match = name.starts_with("cm-")
|| name.starts_with("ctrl-")
|| name.starts_with("mux-")
|| name.starts_with("ssh-")
|| name.contains('@');
if !is_match {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let Ok(metadata) = tokio::fs::metadata(path).await else {
return false;
};
metadata.file_type().is_socket()
}
#[cfg(not(unix))]
{
path.exists()
}
}
pub(crate) fn extract_host_from_socket_path(path: &Path) -> String {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let stripped = name
.strip_prefix("cm-")
.or_else(|| name.strip_prefix("control-"))
.or_else(|| name.strip_prefix("ctrl-"))
.or_else(|| name.strip_prefix("mux-"))
.or_else(|| name.strip_prefix("ssh-"))
.unwrap_or(name);
if let Some(at_pos) = stripped.find('@') {
let after_at = &stripped[at_pos..];
if let Some(colon) = after_at.find(':') {
return stripped[..at_pos + colon].to_string();
}
if let Some(dash) = after_at.find('-') {
return stripped[..at_pos + dash].to_string();
}
return stripped.to_string();
}
if let Some(colon) = stripped.find(':') {
return stripped[..colon].to_string();
}
stripped.to_string()
}
async fn verify_control_session(socket_path: &Path) -> Option<bool> {
let path_str = match socket_path.to_str() {
Some(s) => s.to_owned(),
None => return Some(false),
};
let result = tokio::task::spawn_blocking(move || {
duct::cmd("ssh", ["-S", &path_str, "-O", "check", "localhost"])
.stdout_null()
.stderr_null()
.run()
})
.await;
match result {
Ok(Ok(_)) => Some(true), Ok(Err(e)) => {
if e.kind() == std::io::ErrorKind::NotFound {
tracing::warn!("ssh binary not found, cannot check control socket");
return None;
}
Some(false) }
Err(_) => Some(false), }
}
#[cfg(test)]
#[path = "session.test.rs"]
mod tests;