//! `houndd` — the Hound Antivirus daemon. //! //! A single-purpose service that exposes a pluggable scanning engine plus a //! set of detection subsystems over a Unix-socket, line-delimited JSON-RPC //! 2.0 API. Both the CLI and the GUI are thin clients of this socket, which //! is what lets future suite tools (firewall, updater, …) share the same //! engine without forking it. //! //! ## The subsystems //! //! - **engine** — signature-scan engine, pluggable behind //! [`engine::ScanEngine`] (ClamAV today, native Rust //! engine tomorrow). Wire API only reports `engine` name. //! - **realtime** — inotify monitor: watches configured dirs, scans each //! new/modified file, quarantines or alerts, and feeds a //! rolling write-window into the **ransomware** heuristic. //! - **quarantine** — the vault: files that got caught are moved here. //! - **rootkit** — userspace rootkit heuristics (hidden pids, hidden //! files, writable system dirs, setuid anomalies). //! - **events** — the alert log every subsystem writes into. //! - **settings** — the user-tunable knobs, persisted to XDG config. //! //! ## RPC methods //! status → engine health + realtime + quarantine counts //! scan → recursive scan of a path, per-file findings //! update → refresh the signature store //! settings.get → current knobs //! settings.set → replace the knobs (live-reload realtime) //! events.list → the alert log (newest first) //! events.clear → drop all alerts //! quarantine.list → files held in quarantine //! quarantine.add → move a file into the vault //! quarantine.restore→ put a file back //! quarantine.remove → delete a quarantined file //! rootkit.scan → run the rootkit heuristics //! realtime.status → what's watched, counters, ransomware state //! realtime.set_enabled → toggle the monitor //! //! The engine is selectable at startup for tests: `HOUNDD_ENGINE=fake` //! swaps in [`engine::engine`]'s fake backend (a full daemon lifecycle //! without ClamAV installed). mod engine; mod events; mod quarantine; mod realtime; mod rootkit; mod settings; #[cfg(test)] mod test_util; use anyhow::{bail, Context, Result}; use hound_api::{Response, Settings}; use serde_json::Value; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Shared, daemon-lifetime state. Cheap to clone (everything is `Arc`-shared), /// so the accept loop hands a copy to each connection thread and the realtime /// monitor holds its own copy. #[derive(Clone)] struct DaemonState { settings: settings::SettingsStore, events: events::EventLog, quarantine: quarantine::Quarantine, realtime: realtime::RealtimeMonitor, } fn main() -> Result<()> { let sock = hound_api::default_socket_path(); let sock_path = PathBuf::from(&sock); if let Some(parent) = sock_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("creating socket dir {}", parent.display()))?; } // Reconnect-friendly startup: drop a stale socket from a dead daemon. let _ = fs::remove_file(&sock_path); let listener = UnixListener::bind(&sock_path).with_context(|| format!("binding {sock}"))?; let state = DaemonState::boot(); eprintln!( "houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)", engine::engine().name() ); for stream in listener.incoming() { let stream = match stream { Ok(s) => s, Err(e) => { eprintln!("accept error: {e}"); continue; } }; let state = state.clone(); std::thread::spawn(move || { if let Err(e) = handle_conn(stream, state) { eprintln!("connection error: {e}"); } }); } Ok(()) } impl DaemonState { /// Build the shared state and start the realtime monitor. /// /// The monitor is started lazily: `start()` is idempotent and reads the /// watch dirs from settings at the moment it's called. We start it here /// so the daemon is protective from boot; a `settings.set` that changes /// the watch list calls `start()` again (a no-op if already running — /// the thread picks up new dirs via its own settings reads). fn boot() -> Self { let settings = settings::SettingsStore::load(); let events = events::EventLog::new(); let quarantine = quarantine::Quarantine::new(); let realtime = realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone()); // If realtime is enabled, bring the monitor up. Failures are // non-fatal (e.g. no inotify) — the daemon still serves scans. let s = settings.get(); if s.realtime_enabled && !s.paused { match realtime.start() { Ok(()) => events.push("realtime", "info", "real-time monitor started".into()), Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")), }; } events.push("info", "info", "houndd daemon started".into()); Self { settings, events, quarantine, realtime, } } } /// Read one request line, dispatch, write one response line. fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> { let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line)?; let req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?; let result = dispatch(&req, &state); let resp = match result { Ok(value) => Response { jsonrpc: "2.0".into(), id: req.id, result: Some(value), error: None, }, Err(e) => Response { jsonrpc: "2.0".into(), id: req.id, result: None, error: Some(hound_api::ErrorObject { code: -32000, message: e.to_string(), data: None, }), }, }; let mut out = serde_json::to_string(&resp)?; out.push('\n'); writer_flush(&mut reader, &out)?; Ok(()) } fn writer_flush(reader: &mut BufReader, bytes: &str) -> Result<()> { // The BufReader consumed the stream; get the stream back out to write. let stream = reader.get_mut(); stream.write_all(bytes.as_bytes())?; stream.flush()?; Ok(()) } fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { match req.method.as_str() { "status" => Ok(serde_json::to_value(status(st)?)?), "update" => Ok(serde_json::to_value(update(st)?)?), "scan" => { let path = req .params .as_ref() .and_then(|p| p.get("path")) .and_then(Value::as_str) .context("scan requires params.path")?; let recursive = req .params .as_ref() .and_then(|p| p.get("recursive")) .and_then(Value::as_bool) .unwrap_or(true); Ok(serde_json::to_value(scan(path, recursive)?)?) } // ── settings ── "settings.get" => Ok(serde_json::to_value(st.settings.get())?), "settings.set" => { let incoming: Settings = serde_json::from_value( req.params .clone() .context("settings.set requires a params object")?, )?; st.settings .set(&incoming) .map_err(|e| anyhow::anyhow!("persisting settings: {e}"))?; // Live-reload the realtime monitor: if it's not running and the // new settings ask for it, start it; if the watch list changed, // the thread's per-event settings reads pick it up. let cur = st.settings.get(); if cur.realtime_enabled && !cur.paused && !st.realtime.is_running() { let _ = st.realtime.start(); } Ok(serde_json::to_value(cur)?) } // ── events / alerts ── "events.list" => { let limit = req .params .as_ref() .and_then(|p| p.get("limit")) .and_then(Value::as_u64) .unwrap_or(100) as u32; Ok(serde_json::to_value(st.events.list(limit))?) } "events.clear" => Ok(serde_json::to_value(st.events.clear())?), // ── quarantine ── "quarantine.list" => Ok(serde_json::to_value(st.quarantine.list())?), "quarantine.add" => { let path = req .params .as_ref() .and_then(|p| p.get("path")) .and_then(Value::as_str) .context("quarantine.add requires params.path")?; let virus = req .params .as_ref() .and_then(|p| p.get("virus")) .and_then(Value::as_str) .unwrap_or("manual"); let entry = realtime::quarantine_and_log(&st.quarantine, &st.events, path, virus) .with_context(|| format!("quarantining {path}"))?; Ok(serde_json::to_value(entry)?) } "quarantine.restore" => { let id = req .params .as_ref() .and_then(|p| p.get("id")) .and_then(Value::as_str) .context("quarantine.restore requires params.id")?; let entry = st .quarantine .restore(id) .with_context(|| format!("restoring {id}"))?; st.events.push( "restore", "info", format!("restored {} from quarantine", entry.original_path), ); Ok(serde_json::to_value(entry)?) } "quarantine.remove" => { let id = req .params .as_ref() .and_then(|p| p.get("id")) .and_then(Value::as_str) .context("quarantine.remove requires params.id")?; let freed = st.quarantine.remove(id)?; st.events.push( "quarantine", "info", format!("deleted quarantined file {id} ({freed} bytes)"), ); Ok(serde_json::to_value(freed)?) } // ── rootkit ── "rootkit.scan" => { let dirs = st.settings.get().realtime_watch.clone(); let scan = rootkit::run_scan(&dirs); let sev = if scan.critical > 0 { "critical" } else if scan.warn > 0 { "warn" } else { "info" }; st.events .push("rootkit", sev, format!("rootkit scan: {}", scan.verdict)); Ok(serde_json::to_value(scan)?) } // ── realtime ── "realtime.status" => Ok(serde_json::to_value(st.realtime.status())?), "realtime.set_enabled" => { let enabled = req .params .as_ref() .and_then(|p| p.get("enabled")) .and_then(Value::as_bool) .context("realtime.set_enabled requires params.enabled")?; let mut s = st.settings.get(); s.realtime_enabled = enabled; let updated = st.settings.mutate(|s2| *s2 = s); if enabled && !updated.paused { let _ = st.realtime.start(); } else if !enabled { st.realtime.stop(); } Ok(serde_json::to_value(st.realtime.status())?) } other => bail!("unknown method {other:?}"), } } // ── RPC handlers (engine-agnostic) ────────────────────────────────────────── fn status(st: &DaemonState) -> Result { let (present, db_summary, db) = engine::engine().probe(); let os = std::fs::read_to_string("/etc/os-release") .ok() .and_then(|c| { c.lines().find(|l| l.starts_with("PRETTY_NAME=")).map(|l| { l.trim_start_matches("PRETTY_NAME=") .trim_matches('"') .to_string() }) }) .unwrap_or_else(|| "unknown".into()); Ok(hound_api::Status { daemon_version: DAEMON_VERSION.to_string(), engine: engine::engine().name().to_string(), engine_present: present, db_summary, os, db, realtime: st.realtime.status(), quarantined: st.quarantine.count(), }) } /// Refresh the signature store via the engine, then re-probe so the /// client can refresh its UI/tray from a single round-trip. fn update(st: &DaemonState) -> Result { use hound_api::UpdateResult; let (ok, command, combined) = engine::engine().update()?; st.events.push( "update", if ok { "info" } else { "warn" }, format!( "signature update via {command}: {}", if ok { "ok" } else { "failed" } ), ); Ok(UpdateResult { ok, command, output: cap_tail(&combined, 2048), status: status(st)?, }) } /// Keep the tail of `s` within `max` bytes, back-stepping to a char /// boundary and prefixing an ellipsis marker. fn cap_tail(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let cut = s.len() - max; let cut = s .char_indices() .take_while(|(i, _)| *i < cut) .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(cut); format!("…\n{}", &s[cut..]) } fn scan(path: &str, recursive: bool) -> Result { engine::engine().scan(path, recursive) } #[cfg(test)] mod tests { use super::*; #[test] fn client_type_is_constructible() { let _c: hound_api::Client = hound_api::Client::new("/tmp/does-not-matter.sock".into()); } #[test] fn cap_tail_keeps_char_boundaries() { let s = "é".repeat(3000); let t = cap_tail(&s, 100); assert!(t.len() <= 100 + 4); // "…\n" prefix is 4 bytes assert!(t.starts_with('…')); } /// Full daemon lifecycle with the fake engine: boot, query status, /// round-trip settings, quarantine a file, run the rootkit scan, and /// confirm the realtime status shape — all over a real Unix socket. #[test] fn e2e_fake_engine_over_socket() { use serde_json::json; use std::io::{BufRead, Write as _}; use std::os::unix::net::UnixStream; let dir = std::env::temp_dir().join(format!("hound-e2e-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let sock = dir.join("houndd.sock"); let cfg = dir.join("cfg"); let data = dir.join("data"); // Serialize against any other test that mutates process env. let _env_guard = crate::test_util::locked(); // Isolate every path the daemon touches. std::env::set_var("HOUNDD_ENGINE", "fake"); std::env::set_var("HOUNDD_SOCK", &sock); std::env::set_var("XDG_CONFIG_HOME", &cfg); std::env::set_var("XDG_DATA_HOME", &data); // Boot the listener on a thread (the monitor also starts from here). let listener = UnixListener::bind(&sock).unwrap(); let state = DaemonState::boot(); let shutdown_state = state.clone(); let handle = std::thread::spawn(move || { for stream in listener.incoming() { if let Ok(s) = stream { let state = state.clone(); std::thread::spawn(move || { let _ = handle_conn(s, state); }); } } }); // status — engine present, realtime active, quarantine 0. { let mut c = std::io::BufReader::new(UnixStream::connect(&sock).unwrap()); let req = json!({"jsonrpc":"2.0","id":1,"method":"status"}); c.get_mut() .write_all((serde_json::to_string(&req).unwrap() + "\n").as_bytes()) .unwrap(); let mut line = String::new(); c.read_line(&mut line).unwrap(); let v: Value = serde_json::from_str(line.trim()).unwrap(); let r = v["result"].clone(); assert_eq!(r["engine"].as_str().unwrap(), "fake"); assert!(r["engine_present"].as_bool().unwrap()); assert!(r["realtime"].is_object()); assert_eq!(r["quarantined"].as_u64().unwrap(), 0); } // settings.get then settings.set (round-trip a changed knob). { let client = hound_api::Client::new(sock.to_str().unwrap().to_string()); let mut s = client.settings().unwrap(); assert!(s.recursive_default); s.max_file_size_mb = 42; let back = client.set_settings(&s).unwrap(); assert_eq!(back.max_file_size_mb, 42); assert_eq!(client.settings().unwrap().max_file_size_mb, 42); } // quarantine.add + list + restore. { let client = hound_api::Client::new(sock.to_str().unwrap().to_string()); let f = dir.join("victim.bin"); std::fs::write(&f, b"evil").unwrap(); let entry = client .quarantine_add(f.to_str().unwrap(), "EICAR-Test") .unwrap(); assert!(!entry.restored); assert!(!f.exists()); assert_eq!(client.quarantine_list().unwrap().len(), 1); let restored = client.quarantine_restore(&entry.id).unwrap(); assert!(restored.restored); assert!(f.exists()); } // rootkit.scan returns a well-formed shape. { let client = hound_api::Client::new(sock.to_str().unwrap().to_string()); let scan = client.rootkit_scan().unwrap(); assert!(scan.verdict.contains("clean") || scan.critical + scan.warn + scan.info > 0); let total = scan.findings.len() as u32; assert_eq!(scan.critical + scan.warn + scan.info, total); } // events reflect what happened (at least the boot + scan events). { let client = hound_api::Client::new(sock.to_str().unwrap().to_string()); let evts = client.events(100).unwrap(); assert!(!evts.is_empty()); // Most-recent-first: the rootkit scan is one of the recent events. assert!(evts.iter().any(|e| e.kind == "rootkit")); } // realtime.status shape is intact. { let client = hound_api::Client::new(sock.to_str().unwrap().to_string()); let rt = client.realtime_status().unwrap(); assert!(rt.uptime_secs < 60); } // Clean shutdown: stop the monitor (drops the inotify loop) then // drop the listener so the accept thread can exit. shutdown_state.realtime.stop(); drop(shutdown_state); // The listener thread blocks on incoming(); sending a signal isn't // portable, so just detach it (daemon is in a test process). let _ = handle; std::env::remove_var("HOUNDD_ENGINE"); std::env::remove_var("HOUNDD_SOCK"); std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var("XDG_DATA_HOME"); let _ = std::fs::remove_dir_all(&dir); } }