Antivirus/crates/houndd/src/main.rs
johnmcafee 2cf41ce3bd Rust engine + CLI over ClamAV Unix socket
- hound-api: shared wire types (Status/ScanRequest/ScanResult/Found)
  + line-delimited JSON-RPC client used by both clients
- houndd: daemon binding a Unix socket, dispatching status/scan to
  ClamAV, parsing per-file results, deduping --allmatch hits
- hound: clap CLI (status / scan --json), colored human output,
  exit codes 0=clean 1=threats
- workspace scaffolding: toolchain, gitignore, env example, editorconfig
2026-08-20 16:59:14 -05:00

275 lines
9.1 KiB
Rust

//! `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<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) -> Result<Value> {
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<hound_api::ScanResult> {
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<String> = std::collections::HashSet::new();
let mut reported: std::collections::HashSet<String> = 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());
}
}