use anyhow::{Context, Result, bail};
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' { "→" } else { "←" };
format!(
"pid {:>7} -{} {} {} {}",
self.pid, self.kind, self.spec, arrow, self.host
)
}
pub fn ports(&self) -> Option<(&str, &str, &str)> {
let mut f = self.spec.split(':');
match (f.next(), f.next(), f.next(), f.next()) {
(Some(open), Some(target), Some(port), None) => Some((open, target, port)),
_ => None,
}
}
pub fn explain(&self) -> String {
let Some((open, target, port)) = self.ports() else {
return format!("-{} {}", self.kind, self.spec);
};
let local_target = matches!(target, "localhost" | "127.0.0.1");
match (self.kind, local_target) {
('L', true) => format!("localhost:{open} here is {}'s own {port}", self.host),
('L', false) => format!(
"localhost:{open} here is {target}:{port}, reached from {}",
self.host
),
(_, true) => format!("{}:{open} there is this machine's {port}", self.host),
(_, false) => format!(
"{}:{open} there is {target}:{port}, reached from here",
self.host
),
}
}
pub fn command(&self) -> String {
format!("ssh -N -{} {} {}", self.kind, self.spec, self.host)
}
pub fn started_at(&self) -> Option<SystemTime> {
fs::metadata(&self.log).ok()?.modified().ok()
}
pub fn stderr(&self) -> String {
fs::read_to_string(&self.log)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" · ")
}
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("easysql")
}
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 carrying(local: &str) -> Option<Tunnel> {
list()
.into_iter()
.find(|t| t.kind == 'L' && t.ports().is_some_and(|(open, _, _)| open == local))
}
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));
let stderr = fs::read_to_string(&log).unwrap_or_default();
let first = || {
stderr
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("ssh exited immediately")
.to_string()
};
if !proc_alive(pid) {
let reason = first();
let _ = fs::remove_file(&log);
bail!("{reason}");
}
if forwarding_failed(&stderr) {
let reason = first();
let _ = Command::new("kill").arg(pid.to_string()).status();
let _ = fs::remove_file(&log);
bail!("{reason}");
}
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)
}
fn forwarding_failed(stderr: &str) -> bool {
const FAILURES: [&str; 4] = [
"Address already in use",
"cannot listen to port",
"Could not request local forwarding",
"remote port forwarding failed",
];
FAILURES.iter().any(|f| stderr.contains(f))
}
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(())
}