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,
}
pub struct Entry {
pub owner: Option<String>,
pub kind: char,
pub spec: String,
pub host: String,
pub live: Option<Tunnel>,
}
fn ports_of(spec: &str) -> Option<(&str, &str, &str)> {
let mut f = spec.split(':');
match (f.next(), f.next(), f.next(), f.next()) {
(Some(open), Some(target), Some(port), None) => Some((open, target, port)),
_ => None,
}
}
impl Entry {
pub fn on(&self) -> bool {
self.live.is_some()
}
pub fn pid(&self) -> Option<u32> {
self.live.as_ref().map(|t| t.pid)
}
pub fn describe(&self) -> String {
let arrow = if self.kind == 'L' { "→" } else { "←" };
format!(
"{:<3} -{} {} {} {}",
if self.on() { "on" } else { "off" },
self.kind,
self.spec,
arrow,
self.host
)
}
pub fn ports(&self) -> Option<(&str, &str, &str)> {
ports_of(&self.spec)
}
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)
}
}
impl Tunnel {
pub fn ports(&self) -> Option<(&str, &str, &str)> {
ports_of(&self.spec)
}
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_is_ours(self.pid, self.kind, &self.spec, &self.host)
}
}
fn proc_is_ours(pid: u32, kind: char, spec: &str, host: &str) -> bool {
let Ok(raw) = fs::read(format!("/proc/{pid}/cmdline")) else {
return false;
};
let args: Vec<String> = raw
.split(|b| *b == 0)
.filter(|a| !a.is_empty())
.map(|a| String::from_utf8_lossy(a).into_owned())
.collect();
let flag = format!("-{kind}");
args.contains(&flag) && args.iter().any(|a| a == spec) && args.iter().any(|a| a == host)
}
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_is_ours(pid, kind, spec, host) {
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(())
}