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).
539 lines
20 KiB
Rust
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);
|
|
}
|
|
}
|