use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
#[derive(Clone)]
pub struct Tunnel {
pub pid: u32,
pub kind: char,
pub spec: String,
pub host: String,
}
impl Tunnel {
pub fn describe(&self) -> String {
let arrow = if self.kind == 'L' { "local →" } else { "remote ←" };
format!("pid {:>7} -{} {} {} ({arrow} {})", self.pid, self.kind, self.spec, self.host, self.host)
}
pub fn alive(&self) -> bool {
PathBuf::from(format!("/proc/{}", self.pid)).exists()
}
}
fn state_path() -> PathBuf {
let base = dirs::state_dir()
.or_else(dirs::data_local_dir)
.unwrap_or_else(|| PathBuf::from("."));
base.join("easyssh").join("tunnels.tsv")
}
pub fn list() -> Vec<Tunnel> {
let path = state_path();
let Ok(text) = fs::read_to_string(&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 t = Tunnel {
pid,
kind: kind.chars().next().unwrap_or('L'),
spec: spec.to_string(),
host: host.to_string(),
};
if t.alive() {
live.push(t);
}
}
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{}\n", t.pid, t.kind, t.spec, t.host))
.collect();
fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn open(kind: char, spec: &str, host: &str) -> Result<Tunnel> {
let mut cmd = Command::new("ssh");
cmd.arg("-N")
.arg(format!("-{kind}"))
.arg(spec)
.arg(host)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().with_context(|| "spawning ssh tunnel")?;
let tunnel = Tunnel {
pid: child.id(),
kind,
spec: spec.to_string(),
host: host.to_string(),
};
let mut all = list();
all.push(tunnel.clone());
save(&all)?;
Ok(tunnel)
}
pub fn kill(pid: u32) -> Result<()> {
let _ = Command::new("kill").arg(pid.to_string()).status();
let remaining: Vec<Tunnel> = list().into_iter().filter(|t| t.pid != pid).collect();
save(&remaining)?;
Ok(())
}