- 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
275 lines
9.1 KiB
Rust
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());
|
|
}
|
|
}
|