//! `houndd` — the Hound engine. //! //! A tiny single-purpose daemon that exposes ClamAV 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. //! //! Current methods: //! - `status` → engine health, ClamAV presence, signature-DB summary //! - `scan` → recursive ClamAV scan of a path, returns per-file findings use anyhow::{bail, Context, Result}; use hound_api::Response; use serde_json::Value; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::process::Command; const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); 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}"))?; eprintln!( "houndd {DAEMON_VERSION} listening on {sock} (Ctrl-C to stop)" ); for stream in listener.incoming() { let stream = match stream { Ok(s) => s, Err(e) => { eprintln!("accept error: {e}"); continue; } }; std::thread::spawn(move || { if let Err(e) = handle_conn(stream) { eprintln!("connection error: {e}"); } }); } Ok(()) } /// Read one request line, dispatch, write one response line. fn handle_conn(stream: UnixStream) -> 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); 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) -> Result { match req.method.as_str() { "status" => Ok(serde_json::to_value(status())?), "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)?)?) } other => bail!("unknown method {other:?}"), } } // ── status ────────────────────────────────────────────────────────────────── fn status() -> hound_api::Status { let (present, db_summary) = clamav_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()); hound_api::Status { daemon_version: DAEMON_VERSION.to_string(), clamav_present: present, db_summary, os, } } /// `clamscan --version` for presence; `--stat`-style summary for the DB. fn clamav_probe() -> (bool, String) { let version = Command::new("clamscan") .arg("--version") .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if version.is_empty() { return (false, String::new()); } // freshclam's DB files live in /var/lib/clamav; report newest mtime. let db_dir = "/var/lib/clamav"; let newest = fs::read_dir(db_dir) .into_iter() .flatten() .flatten() .filter(|e| { e.path() .extension() .is_some_and(|ext| ext == "cld" || ext == "ndb") }) .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok())) .max(); let summary = match newest { Some(t) => { let days = std::time::SystemTime::now() .duration_since(t) .map(|d| d.as_secs() / 86_400) .unwrap_or(0); format!("signatures updated {days}d ago (clamav {version})") } None => format!("no signature DB found (run: sudo freshclam) [clamav {version}]"), }; (true, summary) } // ── scan ──────────────────────────────────────────────────────────────────── /// Run `clamscan -r` over a path and parse its deterministic stdout tail: /// /// ```text /// /path/eicar.com: EICAR-Test-File FOUND /// /// ---------------------- /// Scan summary time: ... /// Known viruses: ... /// Scanned files: 3 /// Infected files: 1 /// ``` /// /// We deliberately parse `Infected files` + per-line `FOUND` markers /// instead of `--json` because the text format is stable across ClamAV /// 0.103 → 1.x while `--json` fields have churned. fn scan(path: &str, recursive: bool) -> Result { use hound_api::{Found, ScanResult}; let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?; let mut cmd = Command::new("clamscan"); cmd.arg("--no-summary") .arg("--stdout") .arg("--max-filesize=100M") .arg("--max-scansize=250M"); if recursive { cmd.arg("-r"); } cmd.arg("--allmatch").arg(&path); let out = cmd .output() .context("running clamscan (is ClamAV installed?)")?; // Every file ClamAV looks at emits exactly one stdout line: // /abs/path: OK // /abs/path: VirusName FOUND // /abs/path: INCOMPLETE // We treat those lines as the single source of truth for both the // scanned count and the findings — no second `find` pass that could // disagree (perms, size caps, symlinks). let stdout = String::from_utf8_lossy(&out.stdout); let mut found = Vec::new(); // --allmatch reports *every* signature a file matches (EICAR trips 3), // so the same path can appear on multiple lines. Track unique files for // the scanned total and report each infected file once. let mut seen_files: std::collections::HashSet = std::collections::HashSet::new(); let mut reported: std::collections::HashSet = std::collections::HashSet::new(); for line in stdout.lines() { // A per-file result line starts with the path then ": ". let Some(idx) = line.find(": ") else { continue }; let file = line[..idx].trim(); // Skip the "------" separators and any non-file noise. if !file.starts_with('/') || file.is_empty() { continue; } let key = file.to_string(); seen_files.insert(key.clone()); let body = &line[idx + 2..]; if let Some(end) = body.rfind(" FOUND") { let virus = body[..end].to_string(); if reported.insert(key.clone()) { found.push(Found { path: key, virus }); } } } let scanned = seen_files.len() as u64; let status_code = out.status.code().unwrap_or(-1); if !out.status.success() && status_code != 1 { // 0 = no infections, 1 = infections found, >1 = real error bail!( "clamscan exited {status_code}: {}", String::from_utf8_lossy(&out.stderr).trim() ); } let infected = found.len() as u64; let clean = scanned.saturating_sub(infected); Ok(ScanResult { scanned, clean, infected, found, }) } #[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()); } }