use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DaemonInfo {
pub pid: u32,
pub port: u16,
pub tunnel_type: String,
#[serde(default)]
pub server_host: String,
#[serde(default)]
pub public_port: u16,
}
#[derive(Debug)]
pub struct DaemonEntry {
pub pid: u32,
pub port: u16,
pub tunnel_type: String,
pub remote: String,
}
pub struct DaemonManager {
dir: PathBuf,
}
impl DaemonManager {
pub fn new() -> Self {
let dir = crate::config::gout_dir().join("daemon");
Self { dir }
}
fn ensure_dir(&self) -> Result<()> {
std::fs::create_dir_all(&self.dir).context("create daemon dir")
}
fn pidfile(&self, port: u16) -> PathBuf {
self.dir.join(format!("{port}.json"))
}
fn logfile(&self, port: u16) -> PathBuf {
self.dir.join(format!("{port}.log"))
}
#[cfg(unix)]
fn is_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_alive(pid: u32) -> bool {
std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}")])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
.unwrap_or(false)
}
#[cfg(unix)]
fn terminate(pid: u32) -> Result<()> {
std::process::Command::new("kill")
.args([&pid.to_string()])
.status()
.context("kill failed")?;
Ok(())
}
#[cfg(not(unix))]
fn terminate(pid: u32) -> Result<()> {
std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status()
.context("taskkill failed")?;
Ok(())
}
pub fn list(&self) -> Vec<DaemonEntry> {
let dir = match std::fs::read_dir(&self.dir) {
Ok(d) => d,
Err(_) => return vec![],
};
let mut entries = Vec::new();
let mut stale = Vec::new();
for entry in dir.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let info: DaemonInfo = match serde_json::from_str(&content) {
Ok(i) => i,
Err(_) => continue,
};
if Self::is_alive(info.pid) {
let remote = if info.server_host.is_empty() || info.public_port == 0 {
String::new()
} else {
format!("{}:{}", info.server_host, info.public_port)
};
entries.push(DaemonEntry {
pid: info.pid,
port: info.port,
tunnel_type: info.tunnel_type,
remote,
});
} else {
stale.push(path);
}
}
for p in &stale {
std::fs::remove_file(p).ok();
if let Some(port) = p.file_stem().and_then(|s| s.to_str()) {
if let Ok(port_num) = port.parse::<u16>() {
std::fs::remove_file(self.logfile(port_num)).ok();
}
}
}
entries
}
pub fn start_with_tunnel(
&self,
tunnel_type: &str,
port: u16,
token: u64,
data_port: u16,
public_port: u16,
server_host: &str,
) -> Result<u32> {
self.ensure_dir()?;
let pidfile = self.pidfile(port);
Self::check_existing(&pidfile, port)?;
let exe = std::env::current_exe().context("cannot get own exe path")?;
let logfile = self.logfile(port);
let log_handle = std::fs::File::create(&logfile).context("create log file")?;
let child = std::process::Command::new(&exe)
.args([tunnel_type, &port.to_string()])
.env("GOUT_DAEMON_PIDFILE", pidfile.to_str().unwrap())
.env("GOUT_DAEMON_TOKEN", token.to_string())
.env("GOUT_DAEMON_DATA_PORT", data_port.to_string())
.stdin(std::process::Stdio::null())
.stdout(log_handle.try_clone().context("clone log handle")?)
.stderr(log_handle)
.spawn()
.context("failed to spawn daemon")?;
let info = DaemonInfo {
pid: child.id(),
port,
tunnel_type: tunnel_type.to_string(),
server_host: server_host.to_string(),
public_port,
};
std::fs::write(&pidfile, serde_json::to_string_pretty(&info)?)?;
Ok(child.id())
}
pub fn kill(&self, port: u16) -> Result<()> {
let pidfile = self.pidfile(port);
if !pidfile.exists() {
anyhow::bail!("no daemon record found for port {port}");
}
let content = std::fs::read_to_string(&pidfile)?;
let info: DaemonInfo = serde_json::from_str(&content)?;
if !Self::is_alive(info.pid) {
println!("[!] tunnel on port {} (PID {}) already exited", port, info.pid);
Self::cleanup(&pidfile, &self.logfile(port));
return Ok(());
}
Self::terminate(info.pid)?;
Self::cleanup(&pidfile, &self.logfile(port));
println!("[+] tunnel on port {} (PID {}) stopped", port, info.pid);
Ok(())
}
pub fn read_log(&self, port: u16) -> Result<String> {
let logfile = self.logfile(port);
if !logfile.exists() {
anyhow::bail!("no log file found for port {port}");
}
std::fs::read_to_string(&logfile).context("read log")
}
pub fn follow_log(&self, port: u16) -> Result<()> {
let logfile = self.logfile(port);
if !logfile.exists() {
anyhow::bail!("no log file found for port {port}");
}
#[cfg(unix)]
{
let status = std::process::Command::new("tail")
.args(["-f", &logfile.to_string_lossy()])
.status()
.context("tail failed")?;
if !status.success() {
anyhow::bail!("tail exited with {}", status);
}
Ok(())
}
#[cfg(not(unix))]
{
let content = std::fs::read_to_string(&logfile)?;
print!("{content}");
println!("[!] follow mode (-f) not supported on Windows");
Ok(())
}
}
fn check_existing(pidfile: &Path, port: u16) -> Result<()> {
if pidfile.exists() {
let content = std::fs::read_to_string(pidfile)?;
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&content) {
if Self::is_alive(info.pid) {
anyhow::bail!(
"tunnel on port {} already running (PID {})",
port, info.pid
);
}
}
std::fs::remove_file(pidfile)?;
let logfile = pidfile.with_extension("log");
std::fs::remove_file(logfile).ok();
}
Ok(())
}
fn cleanup(pidfile: &Path, logfile: &Path) {
std::fs::remove_file(pidfile).ok();
std::fs::remove_file(logfile).ok();
}
}
impl Default for DaemonManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl DaemonManager {
pub fn test_new(dir: PathBuf) -> Self {
Self { dir }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_mgr(f: impl FnOnce(&DaemonManager)) {
let tmp = tempfile::tempdir().expect("temp dir");
let dir = tmp.path().join("daemon");
let mgr = DaemonManager::test_new(dir);
f(&mgr);
}
fn write_pid(mgr: &DaemonManager, port: u16, pid: u32) {
let info = DaemonInfo {
pid,
port,
tunnel_type: "tcp".into(),
server_host: String::new(),
public_port: 0,
};
std::fs::create_dir_all(&mgr.dir).unwrap();
std::fs::write(
mgr.pidfile(port),
serde_json::to_string_pretty(&info).unwrap(),
)
.unwrap();
}
#[test]
fn list_empty_when_no_dir() {
let mgr = DaemonManager::test_new(PathBuf::from("/nonexistent/gout/daemon"));
assert!(mgr.list().is_empty());
}
#[test]
fn list_returns_pid_files() {
with_temp_mgr(|mgr| {
write_pid(mgr, 4000, 999_999_999);
let entries = mgr.list();
assert!(entries.is_empty(), "stale PID should be cleaned up");
assert!(!mgr.pidfile(4000).exists(), "PID file should be removed");
});
}
#[test]
fn list_shows_remote() {
with_temp_mgr(|mgr| {
let info = DaemonInfo {
pid: 999_999_999,
port: 4000,
tunnel_type: "http".into(),
server_host: "example.com".into(),
public_port: 10001,
};
std::fs::create_dir_all(&mgr.dir).unwrap();
std::fs::write(
mgr.pidfile(4000),
serde_json::to_string_pretty(&info).unwrap(),
)
.unwrap();
let _entries = mgr.list();
});
}
#[test]
fn kill_missing_port_errors() {
with_temp_mgr(|mgr| {
let err = mgr.kill(9999).unwrap_err();
assert!(err.to_string().contains("no daemon record"));
});
}
#[test]
fn read_log_nonexistent_port() {
with_temp_mgr(|mgr| {
let err = mgr.read_log(9999).unwrap_err();
assert!(err.to_string().contains("no log file"));
});
}
#[test]
fn pidfile_and_logfile_paths() {
with_temp_mgr(|mgr| {
let pf = mgr.pidfile(4000);
assert!(pf.to_string_lossy().ends_with("4000.json"));
let lf = mgr.logfile(4000);
assert!(lf.to_string_lossy().ends_with("4000.log"));
});
}
}