Antivirus/crates/houndd/src/main.rs
johnmcafee 6ef296caa1 Full feature set: realtime monitor, quarantine vault, rootkit scan, settings, events
Daemon (houndd)
- realtime.rs: inotify monitor over watched dirs (default ~/Downloads,
  ~/Documents, ~/Desktop), ClamAV scan on touch, on_detect action
  (quarantine/rename/remove), ransomware heuristic (writes/renames per
  minute above threshold -> 'watching'/'alarm' + critical event)
- quarantine.rs: SHA-256-keyed vault under ~/.local/share/hound/quarantine,
  add/list/restore/remove with original-path metadata
- rootkit.rs: setuid anomaly detection (allowlisted stock binaries),
  deleted-but-executing inodes, world-writable /usr /bin; 3 severity levels
- settings.rs: persisted ~/.config/hound/settings.json, hot-reload on set
- events.rs: ring buffer of severity-tagged events, query + clear

API (hound-api): Settings, Event, QuarantineEntry, RootkitScan/
RootkitFinding, RealtimeStatus types + 10 client methods; Status gains
engine field (engine-agnostic seam)

CLI (hound): events, quarantine list|add|restore|remove, settings
[show|paused|auto-update|notify|realtime on|off|watch|on-detect|
max-size|exclude], rootkit, realtime [status|on|off] — color human
output, --json everywhere

GUI (Tauri 2):
- 16 backend commands bridging every client method
- tray watcher: 1s poll loop, 4-state icon ladder (green/amber/red/gray),
  desktop notification on fresh critical events
- 6-tab frontend: Protection (hero + scan + update), Quarantine (vault
  manager + manual add), Realtime (stats + watch list + toggle), Rootkit
  (on-demand scan), Alerts (event log + clear), Settings (full editor)
- capabilities/default.json for dialog/notification/event permissions

Verified: 27/27 workspace tests, live E2E — EICAR dropped in ~/Downloads
auto-quarantined by the running daemon (critical event logged, file
removed from origin).
2026-08-20 20:33:44 -05:00

539 lines
20 KiB
Rust

//! `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<UnixStream>, 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<Value> {
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<hound_api::Status> {
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<hound_api::UpdateResult> {
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<hound_api::ScanResult> {
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);
}
}