use std::path::Path;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::mpsc;
use crate::session;
const IDLE_TIMEOUT: Duration = Duration::from_mins(5);
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
fn prepare_dir(parent: &Path) -> std::io::Result<()> {
crate::secure_fs::create_private_dir_all(parent)
}
fn write_pid_file(pid_path: &Path) -> std::io::Result<()> {
std::fs::write(pid_path, format!("{}\n", std::process::id()))?;
crate::secure_fs::restrict_file(pid_path)
}
pub async fn run_daemon(socket_path: &Path) -> Result<(), DaemonError> {
if socket_path.exists() {
let _ = std::fs::remove_file(socket_path);
}
if let Some(parent) = socket_path.parent() {
prepare_dir(parent)
.map_err(|e| DaemonError(format!("Failed to create socket dir: {e}")))?;
}
if let Ok(pid_path) = session::daemon_pid_path() {
write_pid_file(&pid_path)
.map_err(|e| DaemonError(format!("Failed to write daemon pid file: {e}")))?;
}
let listener = UnixListener::bind(socket_path)
.map_err(|e| DaemonError(format!("Failed to bind {}: {e}", socket_path.display())))?;
crate::secure_fs::restrict_file(socket_path)
.map_err(|e| DaemonError(format!("Failed to restrict daemon socket: {e}")))?;
eprintln!("daemon ready on {}", socket_path.display());
let (activity_tx, mut activity_rx) = mpsc::channel::<()>(16);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let mut last_activity = Instant::now();
let _heartbeat = tokio::spawn(async move {
let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
loop {
interval.tick().await;
let mut store = match session::load_session() {
Ok(store) => store,
Err(e) => {
eprintln!("daemon heartbeat: could not read the session store: {e}");
continue;
}
};
let before = store.browsers.len();
session::cleanup_stale(&mut store);
if store.browsers.len() != before {
let _ = session::save_session(&mut store);
}
}
});
loop {
tokio::select! {
accept = listener.accept() => {
match accept {
Ok((stream, _addr)) => {
last_activity = Instant::now();
let tx = activity_tx.clone();
let stop = shutdown_tx.clone();
tokio::spawn(handle_client(stream, tx, stop));
}
Err(e) => {
eprintln!("daemon accept error: {e}");
}
}
}
_ = activity_rx.recv() => {
last_activity = Instant::now();
}
_ = shutdown_rx.recv() => {
eprintln!("daemon received stop, exiting");
break;
}
() = tokio::time::sleep(IDLE_TIMEOUT.saturating_sub(last_activity.elapsed())) => {
if last_activity.elapsed() >= IDLE_TIMEOUT {
eprintln!("daemon idle timeout, exiting");
break;
}
}
}
}
let _ = std::fs::remove_file(socket_path);
if let Ok(pid_path) = session::daemon_pid_path() {
let _ = std::fs::remove_file(&pid_path);
}
Ok(())
}
async fn handle_client(stream: UnixStream, activity: mpsc::Sender<()>, shutdown: mpsc::Sender<()>) {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = activity.send(()).await;
let (response, should_shutdown) = process_command(&line);
let json = serde_json::to_string(&response)
.unwrap_or_else(|_| r#"{"ok":false,"error":"serialization failed"}"#.to_string());
if writer
.write_all(format!("{json}\n").as_bytes())
.await
.is_err()
{
break;
}
if should_shutdown {
let _ = shutdown.send(()).await;
break;
}
}
}
fn process_command(line: &str) -> (serde_json::Value, bool) {
let request: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
return (
serde_json::json!({"ok": false, "error": format!("Invalid JSON: {e}")}),
false,
);
}
};
let command = request
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
match command {
"ping" => (serde_json::json!({"ok": true, "data": "pong"}), false),
"status" => {
let store = session::load_session().unwrap_or_else(|e| {
eprintln!("daemon status: could not read the session store: {e}");
session::SessionStore::default()
});
let browsers: Vec<&str> = store
.browsers
.keys()
.map(std::string::String::as_str)
.collect();
(
serde_json::json!({
"ok": true,
"data": {
"pid": std::process::id(),
"browsers": browsers,
}
}),
false,
)
}
"stop" => (serde_json::json!({"ok": true, "data": "stopping"}), true),
_ => (
serde_json::json!({"ok": false, "error": format!("Unknown command: {command}")}),
false,
),
}
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct DaemonError(pub String);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ping_does_not_request_shutdown() {
let (resp, shutdown) = process_command(r#"{"command":"ping"}"#);
assert!(!shutdown);
assert_eq!(resp["ok"], true);
assert_eq!(resp["data"], "pong");
}
#[test]
fn stop_requests_graceful_shutdown() {
let (resp, shutdown) = process_command(r#"{"command":"stop"}"#);
assert!(shutdown, "stop must request shutdown");
assert_eq!(resp["ok"], true);
assert_eq!(resp["data"], "stopping");
}
#[test]
fn invalid_json_reports_error_without_shutdown() {
let (resp, shutdown) = process_command("not json");
assert!(!shutdown);
assert_eq!(resp["ok"], false);
assert!(resp["error"].as_str().unwrap().contains("Invalid JSON"));
}
#[test]
fn unknown_command_reports_error_without_shutdown() {
let (resp, shutdown) = process_command(r#"{"command":"frobnicate"}"#);
assert!(!shutdown);
assert_eq!(resp["ok"], false);
assert!(resp["error"].as_str().unwrap().contains("Unknown command"));
}
#[cfg(unix)]
fn scratch(tag: &str) -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!(
"chrome-agent-daemon-{tag}-{}-{n}",
std::process::id()
))
}
#[cfg(unix)]
#[test]
fn the_daemon_directory_is_not_readable_by_other_users() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("dir");
prepare_dir(&dir).expect("create it");
let mode = std::fs::metadata(&dir).expect("dir").permissions().mode() & 0o777;
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(mode, 0o700, "got {mode:o}");
}
#[cfg(unix)]
#[test]
fn the_pid_file_is_not_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("pid");
prepare_dir(&dir).expect("create it");
let path = dir.join("daemon.pid");
write_pid_file(&path).expect("write pid");
let mode = std::fs::metadata(&path)
.expect("pid file")
.permissions()
.mode()
& 0o777;
let contents = std::fs::read_to_string(&path).expect("pid file");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(mode, 0o600, "got {mode:o}");
assert_eq!(contents.trim(), std::process::id().to_string());
}
#[cfg(unix)]
#[test]
fn the_socket_is_not_connectable_by_other_users() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("sock");
prepare_dir(&dir).expect("create it");
let path = dir.join("daemon.sock");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.expect("a test runtime");
let listener = runtime.block_on(async { UnixListener::bind(&path).expect("bind") });
crate::secure_fs::restrict_file(&path).expect("restrict socket");
let mode = std::fs::metadata(&path)
.expect("socket")
.permissions()
.mode()
& 0o777;
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
mode, 0o600,
"a world-writable socket answers stop to anyone; got {mode:o}"
);
}
#[test]
fn heartbeat_cannot_reset_idle_timer() {
assert!(
HEARTBEAT_INTERVAL < IDLE_TIMEOUT,
"heartbeat must be shorter than the idle timeout — otherwise resetting \
activity on every beat can never let the daemon idle out"
);
}
}