use std::io::Read;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const SOCKET_PATH_MAX: usize = 90;
const PERSIST_IDLE: &str = "10m";
const CONNECT_TIMEOUT_SECS: u64 = 8;
const CONNECT_DEADLINE: Duration = Duration::from_secs(15);
pub fn socket_for(host: &str) -> Option<PathBuf> {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let uid = unsafe { libc::getuid() };
for byte in host.bytes().chain(uid.to_le_bytes()) {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
let name = format!("m-{:012x}.sock", hash & 0xffff_ffff_ffff);
socket_dirs()
.into_iter()
.map(|dir| dir.join(&name))
.find(|path| path.as_os_str().len() <= SOCKET_PATH_MAX)
}
pub fn socket_dirs() -> Vec<PathBuf> {
let runtime = crate::config::runtime_base().join("cctop");
std::iter::once(runtime)
.chain(dirs::home_dir().map(|h| h.join(".ssh")))
.collect()
}
pub fn private_dir(dir: &Path) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
let _ = std::fs::set_permissions(dir, std::os::unix::fs::PermissionsExt::from_mode(0o700));
true
}
pub fn is_up(socket: &Path, host: &str) -> bool {
Command::new("ssh")
.arg("-S")
.arg(socket)
.args(["-O", "check"])
.arg(host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn mux_args(socket: &Path) -> Vec<std::ffi::OsString> {
vec![
"-S".into(),
socket.as_os_str().to_owned(),
"-o".into(),
"ControlMaster=no".into(),
"-o".into(),
"BatchMode=yes".into(),
"-T".into(),
]
}
pub fn failure_reason(stderr: &str, timed_out: bool) -> String {
if timed_out {
return "timed out".to_string();
}
let text = stderr.to_lowercase();
let said = |needle: &str| text.contains(needle);
if said("permission denied") || said("passphrase") || said("keyboard-interactive") {
return "needs a password or key prompt".to_string();
}
if said("host key verification failed") || said("remote host identification has changed") {
return "host key not trusted yet".to_string();
}
if said("could not resolve hostname") {
return "unknown host".to_string();
}
if said("connection refused") {
return "connection refused".to_string();
}
if said("connection timed out") || said("operation timed out") {
return "timed out".to_string();
}
if said("no route to host") || said("network is unreachable") {
return "unreachable".to_string();
}
stderr
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.map(|l| crate::util::truncate(l, 60))
.unwrap_or_else(|| "ssh failed".to_string())
}
static HELD: Mutex<Vec<(String, PathBuf)>> = Mutex::new(Vec::new());
pub fn connect(host: &str) -> Result<PathBuf, String> {
let socket = socket_for(host).ok_or("no directory short enough for an ssh socket")?;
if let Some(dir) = socket.parent()
&& !private_dir(dir)
{
return Err(format!("cannot create {}", dir.display()));
}
if !is_up(&socket, host) {
start(host, &socket)?;
}
take_lease(&socket, std::process::id());
if let Ok(mut held) = HELD.lock()
&& !held.iter().any(|(h, _)| h == host)
{
held.push((host.to_string(), socket.clone()));
}
Ok(socket)
}
fn start(host: &str, socket: &Path) -> Result<(), String> {
let log = socket.with_extension("log");
let stderr = std::fs::File::create(&log).map_err(|e| e.to_string())?;
let mut cmd = Command::new("ssh");
cmd.args(["-f", "-N", "-M", "-S"])
.arg(socket)
.args([
"-o",
"BatchMode=yes",
"-o",
&format!("ControlPersist={PERSIST_IDLE}"),
"-o",
&format!("ConnectTimeout={CONNECT_TIMEOUT_SECS}"),
"-o",
"ClearAllForwardings=yes",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
])
.arg(host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let mut child = cmd.spawn().map_err(|e| format!("could not run ssh: {e}"))?;
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if started.elapsed() > CONNECT_DEADLINE => {
let _ = child.kill();
let _ = child.wait();
break None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(e) => return Err(e.to_string()),
}
};
let said = std::fs::read_to_string(&log).unwrap_or_default();
let _ = std::fs::remove_file(&log);
match status {
Some(s) if s.success() && is_up(socket, host) => Ok(()),
Some(_) => Err(failure_reason(&said, false)),
None => Err(failure_reason(&said, true)),
}
}
fn lease_dir(socket: &Path) -> PathBuf {
let mut name = socket.as_os_str().to_owned();
name.push(".leases");
PathBuf::from(name)
}
pub fn take_lease(socket: &Path, pid: u32) {
let dir = lease_dir(socket);
if private_dir(&dir) {
let _ = std::fs::write(dir.join(pid.to_string()), "");
}
}
fn live_leases(socket: &Path) -> Vec<u32> {
let Ok(entries) = std::fs::read_dir(lease_dir(socket)) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|e| e.file_name().to_str()?.parse::<u32>().ok())
.filter(|pid| Path::new(&format!("/proc/{pid}")).exists())
.collect()
}
pub fn release(host: &str, socket: &Path, pid: u32) -> bool {
release_with(socket, pid, || {
let _ = Command::new("ssh")
.arg("-S")
.arg(socket)
.args(["-O", "exit"])
.arg(host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
})
}
fn release_with(socket: &Path, pid: u32, exit: impl FnOnce()) -> bool {
let dir = lease_dir(socket);
let _ = std::fs::remove_file(dir.join(pid.to_string()));
if !live_leases(socket).is_empty() {
return false;
}
exit();
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(socket);
true
}
pub fn release_all() {
let held = match HELD.lock() {
Ok(mut held) => std::mem::take(&mut *held),
Err(_) => return,
};
for (host, socket) in held {
release(&host, &socket, std::process::id());
}
}
pub trait Runner: Send + Sync {
fn run(&self, host: &str, command: &str, timeout: Duration) -> Result<String, String>;
}
pub struct Ssh;
impl Runner for Ssh {
fn run(&self, host: &str, command: &str, timeout: Duration) -> Result<String, String> {
let socket = socket_for(host).ok_or("no ssh socket")?;
let mut cmd = Command::new("ssh");
cmd.args(mux_args(&socket)).arg(host).arg("--").arg(command);
run_bounded(cmd, timeout)
}
}
pub fn run_bounded(mut cmd: Command, timeout: Duration) -> Result<String, String> {
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("could not run ssh: {e}"))?;
let mut out = child.stdout.take();
let reader = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(out) = out.as_mut() {
let _ = out.read_to_end(&mut buf);
}
buf
});
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() > timeout => {
let _ = child.kill();
let _ = child.wait();
return Err("timed out".to_string());
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(e) => return Err(e.to_string()),
}
};
let stdout = reader.join().unwrap_or_default();
if !status.success() {
let mut err = String::new();
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut err);
}
return Err(failure_reason(&err, false));
}
String::from_utf8(stdout).map_err(|_| "output was not UTF-8".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_socket_is_stable_short_and_per_host() {
let a = socket_for("procdb").expect("a socket path");
assert_eq!(Some(a.clone()), socket_for("procdb"), "the same every time");
assert_ne!(Some(a.clone()), socket_for("devbox"));
assert!(a.as_os_str().len() <= SOCKET_PATH_MAX, "{}", a.display());
let long = socket_for(&format!("someone@{}.example.com", "x".repeat(200)))
.expect("a long host still fits");
assert!(long.as_os_str().len() <= SOCKET_PATH_MAX);
}
#[test]
fn a_failed_connect_says_what_the_person_can_do_about_it() {
assert_eq!(
failure_reason("flo@box: Permission denied (publickey,password).\n", false),
"needs a password or key prompt"
);
assert_eq!(
failure_reason("Host key verification failed.\n", false),
"host key not trusted yet"
);
assert_eq!(
failure_reason(
"ssh: Could not resolve hostname nope: Name or service not known",
false
),
"unknown host"
);
assert_eq!(failure_reason("", true), "timed out");
assert_eq!(
failure_reason("\nsomething odd\n\n", false),
"something odd"
);
assert_eq!(failure_reason("", false), "ssh failed");
}
#[test]
fn a_master_is_kept_while_any_lease_is_live() {
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("m-test.sock");
take_lease(&socket, std::process::id());
take_lease(&socket, 0x7fff_fff0);
assert_eq!(live_leases(&socket), vec![std::process::id()]);
let mut exited = false;
assert!(!release_with(&socket, 0x7fff_fff0, || exited = true));
assert!(!exited);
assert!(release_with(&socket, std::process::id(), || exited = true));
assert!(exited);
assert!(!lease_dir(&socket).exists());
}
#[test]
fn a_command_that_overruns_is_cut_off() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 5"]);
let started = Instant::now();
assert_eq!(
run_bounded(cmd, Duration::from_millis(200)),
Err("timed out".to_string())
);
assert!(started.elapsed() < Duration::from_secs(3));
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo hi"]);
assert_eq!(run_bounded(cmd, Duration::from_secs(5)), Ok("hi\n".into()));
}
}