use anyhow::{bail, Context, Result};
use std::fs::{self, File};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct Tunnel {
pub pid: u32,
pub kind: char,
pub spec: String,
pub host: String,
pub log: PathBuf,
}
impl Tunnel {
pub fn describe(&self) -> String {
let arrow = if self.kind == 'L' { "local →" } else { "remote ←" };
format!("pid {:>7} -{} {} {} ({} {})", self.pid, self.kind, self.spec, self.host, arrow, self.host)
}
pub fn alive(&self) -> bool {
proc_alive(self.pid)
}
}
fn proc_alive(pid: u32) -> bool {
PathBuf::from(format!("/proc/{pid}")).exists()
}
fn state_dir() -> PathBuf {
dirs::state_dir()
.or_else(dirs::data_local_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("easyssh")
}
fn state_path() -> PathBuf {
state_dir().join("tunnels.tsv")
}
fn log_path() -> PathBuf {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
state_dir().join("tunnels").join(format!("{stamp}.log"))
}
pub fn list() -> Vec<Tunnel> {
let Ok(text) = fs::read_to_string(state_path()) else {
return Vec::new();
};
let mut live = Vec::new();
for line in text.lines() {
let mut f = line.split('\t');
let (Some(pid), Some(kind), Some(spec), Some(host)) = (f.next(), f.next(), f.next(), f.next()) else {
continue;
};
let Ok(pid) = pid.parse::<u32>() else { continue };
let log = f.next().map(PathBuf::from).unwrap_or_default();
let t = Tunnel {
pid,
kind: kind.chars().next().unwrap_or('L'),
spec: spec.to_string(),
host: host.to_string(),
log,
};
if t.alive() {
live.push(t);
} else if !t.log.as_os_str().is_empty() {
let _ = fs::remove_file(&t.log); }
}
let _ = save(&live);
live
}
fn save(tunnels: &[Tunnel]) -> Result<()> {
let path = state_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body: String = tunnels
.iter()
.map(|t| format!("{}\t{}\t{}\t{}\t{}\n", t.pid, t.kind, t.spec, t.host, t.log.display()))
.collect();
fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn open(kind: char, spec: &str, host: &str) -> Result<Tunnel> {
let log = log_path();
if let Some(parent) = log.parent() {
fs::create_dir_all(parent)?;
}
let errfile = File::create(&log).with_context(|| format!("creating {}", log.display()))?;
let mut cmd = Command::new("ssh");
cmd.arg("-N")
.arg(format!("-{kind}"))
.arg(spec)
.arg(host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(errfile));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().with_context(|| "spawning ssh tunnel")?;
let pid = child.id();
sleep(Duration::from_millis(800));
if !proc_alive(pid) {
let reason = fs::read_to_string(&log).unwrap_or_default();
let _ = fs::remove_file(&log);
let first = reason.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("ssh exited immediately");
bail!("{first}");
}
let tunnel = Tunnel {
pid,
kind,
spec: spec.to_string(),
host: host.to_string(),
log,
};
let mut all = list();
all.push(tunnel.clone());
save(&all)?;
Ok(tunnel)
}
pub fn kill(pid: u32) -> Result<()> {
let all = list();
if let Some(t) = all.iter().find(|t| t.pid == pid) {
let _ = fs::remove_file(&t.log);
}
let _ = Command::new("kill").arg(pid.to_string()).status();
let remaining: Vec<Tunnel> = all.into_iter().filter(|t| t.pid != pid).collect();
save(&remaining)?;
Ok(())
}