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).
This commit is contained in:
John McAfee 2026-08-20 20:33:44 -05:00
parent a7871b5e27
commit 6ef296caa1
29 changed files with 4417 additions and 99 deletions

3
.gitignore vendored
View file

@ -4,7 +4,8 @@
# ── Node / GUI ───────────────────────────────────────
gui/node_modules/
gui/dist/
# gui/dist is the hand-maintained static frontend (no build step) — tracked
gui/dist/assets/
gui/src-tauri/target/
# ── Env & secrets (repo-local credential file, chmod 600) ──

87
Cargo.lock generated
View file

@ -58,6 +58,12 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "clap"
version = "4.6.6"
@ -123,6 +129,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "heck"
version = "0.5.0"
@ -156,11 +168,34 @@ version = "0.1.0"
dependencies = [
"anyhow",
"hound-api",
"inotify",
"serde",
"serde_json",
"time",
]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags",
"futures-core",
"inotify-sys",
"libc",
"tokio",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@ -179,12 +214,29 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@ -197,6 +249,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -264,6 +322,16 @@ dependencies = [
"zmij",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -311,6 +379,19 @@ dependencies = [
"time-core",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -323,6 +404,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"

View file

@ -15,6 +15,7 @@ serde_json = "1"
clap = { version = "4", features = ["derive"] }
colored = "2"
time = { version = "0.3", features = ["serde", "std", "formatting"] }
inotify = "0.10"
[profile.release]
lto = true

View file

@ -18,8 +18,7 @@ pub fn default_socket_path() -> String {
if let Ok(sock) = std::env::var("HOUNDD_SOCK") {
return sock;
}
let runtime = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| "/run/user/1000".to_string());
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
format!("{runtime}/houndd.sock")
}
@ -68,7 +67,7 @@ impl Response {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Status {
pub daemon_version: String,
/// Which engine the daemon is running (see `houndd::engine::ENGINE`).
/// Which engine the daemon is running (see `houndd::engine::engine()`).
#[serde(default)]
pub engine: String,
/// Whether the active engine is present/usable on this machine —
@ -83,6 +82,12 @@ pub struct Status {
/// stale → amber" instead of parsing `db_summary` prose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db: Option<DbFile>,
/// Real-time interception status (watching, counters, uptime).
#[serde(default)]
pub realtime: RealtimeStatus,
/// Number of files currently held in quarantine.
#[serde(default)]
pub quarantined: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -126,6 +131,157 @@ impl ScanResult {
}
}
// ── Settings ────────────────────────────────────────────────────────────────
/// The user-tunable knobs. Stored by the daemon, editable from the GUI
/// or CLI. The daemon reloads the relevant parts live (realtime, pause).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
// Scanning
/// Scan directories recursively by default.
pub recursive_default: bool,
/// Per-file size cap for clamscan (MB).
pub max_file_size_mb: u64,
/// Paths (exact or `/`-suffixed prefixes) skipped by scans.
pub exclude_paths: Vec<String>,
// Real-time interception
pub realtime_enabled: bool,
/// Directories the realtime monitor watches (recursively).
pub realtime_watch: Vec<String>,
/// What to do when realtime flags a file: "quarantine" | "alert".
pub on_detect: String,
// Ransomware avoidance (write-burst heuristic)
pub ransomware_guard: bool,
/// File-write events per minute that trips the ransomware alarm.
pub ransomware_threshold_per_min: u32,
// Rootkit detection
pub rootkit_enabled: bool,
// Alerts
/// Emit desktop notifications for critical alerts (GUI consumes events).
pub notify_desktop: bool,
/// Run the signature update automatically (daemon schedules it).
pub auto_update_signatures: bool,
// Global
/// Master switch — when true, realtime is suspended and the tray is gray.
pub paused: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
recursive_default: true,
max_file_size_mb: 100,
exclude_paths: vec!["/proc".into(), "/sys".into(), "/dev".into()],
realtime_enabled: true,
realtime_watch: vec!["~/Downloads".into()],
on_detect: "quarantine".into(),
ransomware_guard: true,
ransomware_threshold_per_min: 40,
rootkit_enabled: true,
notify_desktop: true,
auto_update_signatures: true,
paused: false,
}
}
}
impl Settings {
/// Merge `incoming` over `self`, but keep fields the client left as
/// their defaults if it sent a partially-filled struct. (Kept simple:
/// the GUI always sends the full object, so this is a plain copy — but
/// the seam is here if we later add sparse updates.)
pub fn apply(&mut self, incoming: Settings) {
*self = incoming;
}
}
// ── Events (the alert log) ──────────────────────────────────────────────────
/// Severity is a plain string on the wire for forward-compat; the daemon
/// constrains it to info / warn / critical.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: u64,
/// RFC3339 UTC timestamp.
pub ts: String,
/// What happened: scan / threat / quarantine / restore / ransomware /
/// rootkit / update / realtime / info.
pub kind: String,
/// info / warn / critical.
pub severity: String,
pub message: String,
}
// ── Quarantine ──────────────────────────────────────────────────────────────
/// A file held in the quarantine store (moved out of its original place,
/// renamed to a generated name, with a metadata sidecar).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuarantineEntry {
/// Stable id (hash of original path + quarantine time).
pub id: String,
pub original_path: String,
/// Where the bytes live now, inside the quarantine store.
pub quarantined_path: String,
/// Signature that caught it (or "manual").
pub virus: String,
pub size: u64,
/// RFC3339 quarantine time.
pub ts: String,
/// True after it has been restored to `original_path`.
pub restored: bool,
}
// ── Real-time interception ──────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RealtimeStatus {
pub enabled: bool,
/// Resolved directories actually being watched right now.
pub watching: Vec<String>,
/// Total file events observed by the monitor.
pub files_seen: u64,
/// Files pulled into quarantine by the monitor.
pub files_quarantined: u64,
/// Last time the monitor saw a file event.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_event_at: Option<String>,
/// Seconds since the monitor thread started.
pub uptime_secs: u64,
/// Whether the inotify loop is currently alive.
pub active: bool,
/// Last ransomware heuristic state: "calm" | "watching" | "alarm".
pub ransomware: String,
}
// ── Rootkit detection ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootkitFinding {
/// Which check flagged this (e.g. "deleted_exe", "world_writable_bin").
pub check: String,
/// info / warn / critical.
pub severity: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RootkitScan {
/// Aggregate verdict for the banner: "clean" or "N critical, M warning(s)".
pub verdict: String,
pub critical: u32,
pub warn: u32,
pub info: u32,
pub findings: Vec<RootkitFinding>,
/// RFC3339 scan time.
pub ts: String,
}
// ── Client ──────────────────────────────────────────────────────────────────
/// A small blocking JSON-RPC client over a Unix socket.
@ -147,12 +303,7 @@ impl Client {
}
/// Connect and issue a single request. Returns the decoded `result`.
pub fn call(
&self,
id: u64,
method: &str,
params: Option<Value>,
) -> anyhow::Result<Value> {
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
let mut stream = UnixStream::connect(&self.sock)
.map_err(|e| anyhow::anyhow!("cannot reach houndd at {}: {e}", self.sock))?;
let req = Request {
@ -196,4 +347,81 @@ impl Client {
let v = self.call(3, "update", None)?;
Ok(serde_json::from_value(v)?)
}
// ── settings ──
pub fn settings(&self) -> anyhow::Result<Settings> {
let v = self.call(4, "settings.get", None)?;
Ok(serde_json::from_value(v)?)
}
pub fn set_settings(&self, s: &Settings) -> anyhow::Result<Settings> {
let v = self.call(5, "settings.set", Some(serde_json::to_value(s)?))?;
Ok(serde_json::from_value(v)?)
}
// ── events ──
pub fn events(&self, limit: u32) -> anyhow::Result<Vec<Event>> {
let v = self.call(
6,
"events.list",
Some(serde_json::json!({ "limit": limit })),
)?;
Ok(serde_json::from_value(v)?)
}
pub fn clear_events(&self) -> anyhow::Result<u64> {
let v = self.call(7, "events.clear", None)?;
Ok(serde_json::from_value(v)?)
}
// ── quarantine ──
pub fn quarantine_list(&self) -> anyhow::Result<Vec<QuarantineEntry>> {
let v = self.call(8, "quarantine.list", None)?;
Ok(serde_json::from_value(v)?)
}
pub fn quarantine_add(&self, path: &str, virus: &str) -> anyhow::Result<QuarantineEntry> {
let params = serde_json::json!({ "path": path, "virus": virus });
let v = self.call(9, "quarantine.add", Some(params))?;
Ok(serde_json::from_value(v)?)
}
pub fn quarantine_restore(&self, id: &str) -> anyhow::Result<QuarantineEntry> {
let v = self.call(
10,
"quarantine.restore",
Some(serde_json::json!({ "id": id })),
)?;
Ok(serde_json::from_value(v)?)
}
pub fn quarantine_remove(&self, id: &str) -> anyhow::Result<u64> {
let v = self.call(
11,
"quarantine.remove",
Some(serde_json::json!({ "id": id })),
)?;
Ok(serde_json::from_value(v)?)
}
// ── rootkit ──
pub fn rootkit_scan(&self) -> anyhow::Result<RootkitScan> {
let v = self.call(12, "rootkit.scan", None)?;
Ok(serde_json::from_value(v)?)
}
// ── realtime ──
pub fn realtime_status(&self) -> anyhow::Result<RealtimeStatus> {
let v = self.call(13, "realtime.status", None)?;
Ok(serde_json::from_value(v)?)
}
pub fn realtime_set_enabled(&self, enabled: bool) -> anyhow::Result<RealtimeStatus> {
let v = self.call(
14,
"realtime.set_enabled",
Some(serde_json::json!({ "enabled": enabled })),
)?;
Ok(serde_json::from_value(v)?)
}
}

View file

@ -14,7 +14,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use hound_api::{Client, ScanResult, UpdateResult};
use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult};
#[derive(Parser)]
#[command(
@ -52,6 +52,86 @@ enum Cmd {
#[arg(long)]
json: bool,
},
/// Show recent alerts (scan / threat / quarantine / ransomware …)
Events {
/// How many events to show (default 20)
#[arg(long, default_value_t = 20)]
limit: u32,
/// Emit machine-readable JSON instead of human text
#[arg(long)]
json: bool,
},
/// Clear the alert log
ClearEvents,
/// Manage the quarantine vault
Quarantine {
#[command(subcommand)]
action: QuarantineCmd,
},
/// Show or change Hound settings
Settings {
#[command(subcommand)]
action: Option<SettingsCmd>,
},
/// Run userspace rootkit heuristics
Rootkit {
/// Emit machine-readable JSON instead of human text
#[arg(long)]
json: bool,
},
/// Real-time protection status / on-off toggle
Realtime {
#[command(subcommand)]
action: Option<RealtimeCmd>,
},
}
#[derive(Subcommand)]
enum QuarantineCmd {
/// List quarantined files
List,
/// Move a file into quarantine (removes it from its original place)
Add {
path: String,
/// Label to record (default: manual)
#[arg(long, default_value = "manual")]
virus: String,
},
/// Restore a quarantined file to its original path
Restore { id: String },
/// Delete a quarantined file for good (frees its bytes)
Remove { id: String },
}
#[derive(Subcommand)]
enum SettingsCmd {
/// Print current settings
Show,
/// Turn real-time protection on/off
Realtime {
#[arg(value_parser = ["on", "off"])]
mode: String,
},
/// Pause or resume Hound entirely (tray goes gray)
Pause {
#[arg(long)]
resume: bool,
},
/// Set the ransomware write-burst threshold (writes per minute)
RansomwareThreshold { value: u32 },
/// What to do when realtime flags a file: quarantine | alert
OnDetect {
#[arg(value_parser = ["quarantine", "alert"])]
mode: String,
},
}
#[derive(Subcommand)]
enum RealtimeCmd {
/// Turn the monitor off
Off,
/// Turn the monitor on
On,
}
fn main() {
@ -96,7 +176,10 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
println!(" Engine: {}", "present".green());
println!(" Signatures:{}", st.db_summary);
if let Some(db) = &st.db {
println!(" DB file: {} (last modified {})", db.file, db.updated_at);
println!(
" DB file: {} (last modified {})",
db.file, db.updated_at
);
}
} else {
println!(
@ -135,6 +218,181 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
print_update_human(&u);
Ok(if u.ok { 0 } else { 1 })
}
Cmd::Events { limit, json } => {
let evs = client.events(*limit)?;
if *json {
println!("{}", serde_json::to_string_pretty(&evs)?);
} else if evs.is_empty() {
println!("{} no events logged yet", "".dimmed());
} else {
for e in &evs {
let sev = match e.severity.as_str() {
"critical" => e.severity.red().bold(),
"warn" => e.severity.yellow().bold(),
_ => e.severity.dimmed(),
};
println!("{} [{:<8}] {}{}", e.ts.dimmed(), sev, e.kind, e.message);
}
}
Ok(0)
}
Cmd::ClearEvents => {
let n = client.clear_events()?;
println!("{} {} event(s) cleared from the log", "".green().bold(), n);
Ok(0)
}
Cmd::Quarantine { action } => match action {
QuarantineCmd::List => {
let list = client.quarantine_list()?;
if list.is_empty() {
println!("{} vault is empty", "".dimmed());
} else {
for q in &list {
let mark = if q.restored {
"restored".dimmed().to_string()
} else {
q.virus.red().to_string()
};
println!(
"{} {} {} ({}, {})",
q.id,
mark,
q.original_path,
q.ts,
human_size(q.size)
);
}
}
Ok(0)
}
QuarantineCmd::Add { path, virus } => {
let q = client.quarantine_add(path, virus)?;
println!(
"{} {} → vault ({})",
"".green().bold(),
q.original_path,
q.id
);
Ok(0)
}
QuarantineCmd::Restore { id } => {
let q = client.quarantine_restore(id)?;
println!(
"{} restored {} → {}",
"".green().bold(),
q.id,
q.original_path
);
Ok(0)
}
QuarantineCmd::Remove { id } => {
let freed = client.quarantine_remove(id)?;
println!(
"{} deleted {} — freed {}",
"".green().bold(),
id,
human_size(freed)
);
Ok(0)
}
},
Cmd::Settings { action } => {
let s = client.settings()?;
match action {
None | Some(SettingsCmd::Show) => print_settings_human(&s),
Some(SettingsCmd::Realtime { mode }) => {
let mut next = s.clone();
next.realtime_enabled = mode == "on";
let s2 = client.set_settings(&next)?;
println!(
"{} real-time protection {}",
"".green().bold(),
if s2.realtime_enabled {
"enabled".green().to_string()
} else {
"disabled".yellow().to_string()
}
);
}
Some(SettingsCmd::Pause { resume }) => {
let mut next = s.clone();
next.paused = !*resume;
let s2 = client.set_settings(&next)?;
println!(
"{} Hound {}",
"".green().bold(),
if s2.paused {
"paused".yellow().to_string()
} else {
"resumed".green().to_string()
}
);
}
Some(SettingsCmd::RansomwareThreshold { value }) => {
let mut next = s.clone();
next.ransomware_threshold_per_min = *value;
let s2 = client.set_settings(&next)?;
println!(
"{} ransomware threshold set to {} writes/min",
"".green().bold(),
s2.ransomware_threshold_per_min
);
}
Some(SettingsCmd::OnDetect { mode }) => {
let mut next = s.clone();
next.on_detect = mode.clone();
let s2 = client.set_settings(&next)?;
println!(
"{} on-detect action: {}",
"".green().bold(),
s2.on_detect.yellow()
);
}
}
Ok(0)
}
Cmd::Rootkit { json } => {
let r: RootkitScan = client.rootkit_scan()?;
if *json {
println!("{}", serde_json::to_string_pretty(&r)?);
return Ok(0);
}
print_rootkit_human(&r);
Ok(0)
}
Cmd::Realtime { action } => {
match action {
None => {
let r: RealtimeStatus = client.realtime_status()?;
print_realtime_human(&r);
}
Some(RealtimeCmd::On) => {
let r = client.realtime_set_enabled(true)?;
println!(
"{} real-time protection {}",
"".green().bold(),
if r.active {
"on".green().to_string()
} else {
"enabled (monitor starting)".yellow().to_string()
}
);
}
Some(RealtimeCmd::Off) => {
let r = client.realtime_set_enabled(false)?;
println!(
"{} real-time protection {}",
"".green().bold(),
if r.active {
"stopping…".yellow().to_string()
} else {
"off".dimmed().to_string()
}
);
}
}
Ok(0)
}
}
}
@ -146,11 +404,7 @@ fn print_update_human(u: &UpdateResult) {
u.command
);
} else {
println!(
"{} update failed via `{}`",
"".red().bold(),
u.command
);
println!("{} update failed via `{}`", "".red().bold(), u.command);
}
// Show the tail of the run (errors, "already current", etc.).
for line in u.output.lines() {
@ -182,6 +436,111 @@ fn print_update_human(u: &UpdateResult) {
}
}
/// 1234 → "1.2 KB" etc.
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = bytes as f64;
let mut u = 0;
while v >= 1024.0 && u < UNITS.len() - 1 {
v /= 1024.0;
u += 1;
}
if u == 0 {
format!("{} {}", bytes, UNITS[0])
} else {
format!("{v:.1} {}", UNITS[u])
}
}
fn print_settings_human(s: &Settings) {
let on = |b: bool| {
if b {
"on".green().to_string()
} else {
"off".dimmed().to_string()
}
};
println!("{} {}", "Hound settings:", "settings:".bold());
println!(" Recursion: {}", s.recursive_default);
println!(" Max file size: {} MB", s.max_file_size_mb);
println!(
" Excluded paths: {}",
s.exclude_paths.join(", ").dimmed()
);
println!(" Realtime: {}", on(s.realtime_enabled));
println!(
" Realtime watches: {}",
s.realtime_watch.join(", ").dimmed()
);
println!(" On detect: {}", s.on_detect.yellow());
println!(
" Ransomware guard: {} ({} writes/min)",
on(s.ransomware_guard),
s.ransomware_threshold_per_min
);
println!(" Rootkit checks: {}", on(s.rootkit_enabled));
println!(" Desktop alerts: {}", on(s.notify_desktop));
println!(" Auto signature up.: {}", on(s.auto_update_signatures));
println!(
" Paused: {}",
if s.paused {
"yes (protection suspended)".yellow().to_string()
} else {
"no".green().to_string()
}
);
}
fn print_rootkit_human(r: &RootkitScan) {
let verdict = if r.critical > 0 {
r.verdict.red().bold().to_string()
} else if r.warn > 0 {
r.verdict.yellow().bold().to_string()
} else {
r.verdict.green().bold().to_string()
};
println!("{} rootkit check — {}", "🐕".to_string(), verdict);
println!(
" {} critical, {} warning(s), {} info ({})",
r.critical,
r.warn,
r.info,
r.ts.dimmed()
);
if r.findings.is_empty() {
println!(" {} no anomalies detected", "".green());
}
for f in &r.findings {
let sev = match f.severity.as_str() {
"critical" => f.severity.red().bold(),
"warn" => f.severity.yellow().bold(),
_ => f.severity.dimmed(),
};
println!(" [{:<8}] {}: {}", sev, f.check, f.detail);
}
}
fn print_realtime_human(r: &RealtimeStatus) {
let state = if !r.active {
"inactive".dimmed().to_string()
} else {
match r.ransomware.as_str() {
"alarm" => "🚨 RANSOMWARE ALARM".red().bold().to_string(),
"watching" => "👁 watching (write burst)".yellow().bold().to_string(),
_ => "● active — calm".green().bold().to_string(),
}
};
println!("{} {}", "Real-time protection:", state);
println!(" Enabled: {}", r.enabled);
println!(" Watching: {}", r.watching.join(", ").dimmed());
println!(" Files seen: {}", r.files_seen);
println!(" Quarantined: {}", r.files_quarantined);
println!(" Uptime: {}s", r.uptime_secs);
if let Some(last) = &r.last_event_at {
println!(" Last event: {}", last.dimmed());
}
}
fn print_human(r: &ScanResult, path: &str) {
if r.is_clean() {
println!(

View file

@ -1,6 +1,6 @@
[package]
name = "houndd"
description = "Hound daemon: ClamAV-backed Unix-socket JSON-RPC engine"
description = "Hound Antivirus daemon — Unix-socket JSON-RPC over a pluggable engine"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -16,3 +16,4 @@ anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
time.workspace = true
inotify.workspace = true

View file

@ -55,7 +55,11 @@ impl ScanEngine for ClamAvEngine {
}
// freshclam's DB files live in /var/lib/clamav; report the newest.
let mut newest: Option<(String, std::time::SystemTime)> = None;
for entry in fs::read_dir("/var/lib/clamav").into_iter().flatten().flatten() {
for entry in fs::read_dir("/var/lib/clamav")
.into_iter()
.flatten()
.flatten()
{
let path = entry.path();
if !path
.extension()
@ -86,9 +90,7 @@ impl ScanEngine for ClamAvEngine {
.unwrap_or(0);
(
true,
format!(
"signatures updated {days}d ago ({file}) [clamav {version}]"
),
format!("signatures updated {days}d ago ({file}) [clamav {version}]"),
Some(DbFile {
file,
updated_at: to_rfc3339(t),
@ -119,7 +121,10 @@ impl ScanEngine for ClamAvEngine {
.output()
.context("running clamscan (is ClamAV installed?)")?;
Ok(parse_clamscan(&out.stdout, out.status.code().unwrap_or(-1))?)
Ok(parse_clamscan(
&out.stdout,
out.status.code().unwrap_or(-1),
)?)
}
fn update(&self) -> Result<(bool, String, String)> {
@ -207,7 +212,7 @@ pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
}
// 0 = no infections, 1 = infections found, >1 = real error.
if !((exit_code == 0 || exit_code == 1)) {
if !(exit_code == 0 || exit_code == 1) {
anyhow::bail!("clamscan exited {exit_code}");
}
@ -235,10 +240,79 @@ pub fn to_rfc3339(t: std::time::SystemTime) -> String {
dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into())
}
/// Which engine the daemon serves. Flip this when the native engine
/// lands — the whole point of the trait is that this is the only change
/// the daemon needs.
pub const ENGINE: ClamAvEngine = ClamAvEngine;
/// The active engine, chosen at daemon startup.
///
/// Default is [`ClamAvEngine`]. Set `HOUNDD_ENGINE=fake` to the
/// [`FakeEngine`] — used by the E2E test so it can drive a full
/// daemon lifecycle (status, scan, settings, quarantine, rootkit)
/// without requiring ClamAV or a real filesystem of .cld files.
///
/// The whole point of the trait is that this is the only place the
/// daemon decides *which* engine it serves.
pub fn engine() -> &'static dyn ScanEngine {
if std::env::var_os("HOUNDD_ENGINE").is_some_and(|v| v == "fake") {
static FAKE: FakeEngine = FakeEngine;
&FAKE
} else {
static CLAMAV: ClamAvEngine = ClamAvEngine;
&CLAMAV
}
}
/// Test engine: reports itself present, scans anything whose name
/// contains "EICAR" or ".eicar" as infected, and updates cleanly.
/// Lets the E2E test exercise the full wire without ClamAV installed.
pub struct FakeEngine;
impl ScanEngine for FakeEngine {
fn name(&self) -> &'static str {
"fake"
}
fn probe(&self) -> (bool, String, Option<DbFile>) {
(
true,
"signatures: synthetic [fake]".to_string(),
Some(DbFile {
file: "fake.cld".to_string(),
updated_at: to_rfc3339(std::time::SystemTime::now()),
}),
)
}
fn scan(&self, path: &str, _recursive: bool) -> Result<ScanResult> {
use hound_api::Found;
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
let infected = path
.to_string_lossy()
.to_ascii_lowercase()
.contains("eicar");
let found = if infected {
vec![Found {
path: path.to_string_lossy().to_string(),
virus: "Fake-Eicar".to_string(),
}]
} else {
Vec::new()
};
let scanned: u64 = 1;
let infected_u: u64 = if infected { 1 } else { 0 };
Ok(ScanResult {
scanned,
clean: scanned - infected_u,
infected: infected_u,
found,
})
}
fn update(&self) -> Result<(bool, String, String)> {
Ok((
true,
"fake update".to_string(),
"OK: fake DB refreshed\n".to_string(),
))
}
}
#[cfg(test)]
mod tests {
@ -316,12 +390,15 @@ mod tests {
let (present, _summary, _db) = ClamAvEngine.probe();
// CI boxes without ClamAV: presence is whatever the OS says.
let via_cmd = Command::new("clamscan").arg("--version").output();
assert_eq!(present, via_cmd.map(|o| o.status.success()).unwrap_or(false));
assert_eq!(
present,
via_cmd.map(|o| o.status.success()).unwrap_or(false)
);
}
#[test]
fn trait_is_object_safe() {
fn take(_e: &dyn ScanEngine) {}
take(&ENGINE);
take(engine());
}
}

134
crates/houndd/src/events.rs Normal file
View file

@ -0,0 +1,134 @@
//! The alert/event log — a ring buffer the daemon appends to and the
//! GUI/CLI read. This is the "alerts" the user asked for: every threat,
//! quarantine, ransomware alarm, rootkit finding, and signature update
//! lands here with a severity the GUI can color-code and (optionally)
//! surface as a desktop notification.
//!
//! It is intentionally in-memory (bounded) rather than a growing log file:
//! the GUI polls it live, and a bounded buffer is enough for a desktop
//! AV's working history. Persistence can be layered on later without
//! changing the wire shape.
use hound_api::Event;
use std::sync::{Arc, Mutex};
/// How many events we keep. 500 is plenty for a working session and keeps
/// `events.list` fast.
const CAPACITY: usize = 500;
/// Shared, thread-safe alert log.
#[derive(Clone)]
pub struct EventLog {
inner: Arc<Mutex<Vec<Event>>>,
next_id: Arc<std::sync::atomic::AtomicU64>,
}
impl EventLog {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Vec::new())),
next_id: Arc::new(std::sync::atomic::AtomicU64::new(1)),
}
}
/// Append an event, assigning it a monotonically increasing id.
/// Returns the new id.
pub fn push(&self, kind: &str, severity: &str, message: String) -> u64 {
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let ev = Event {
id,
ts: now_rfc3339(),
kind: kind.to_string(),
severity: severity.to_string(),
message,
};
let mut guard = self.inner.lock().unwrap();
guard.push(ev);
if guard.len() > CAPACITY {
let drop = guard.len() - CAPACITY;
guard.drain(0..drop);
}
id
}
/// Most-recent-first list, capped at `limit` (0 = all).
pub fn list(&self, limit: u32) -> Vec<Event> {
let guard = self.inner.lock().unwrap();
let mut v: Vec<Event> = guard.iter().rev().cloned().collect();
if limit > 0 {
v.truncate(limit as usize);
}
v
}
/// Drop all events. Returns how many were cleared.
pub fn clear(&self) -> u64 {
let mut guard = self.inner.lock().unwrap();
let n = guard.len();
guard.clear();
n as u64
}
}
impl Default for EventLog {
fn default() -> Self {
Self::new()
}
}
/// RFC3339 UTC "now" — reuses the engine's formatter for a single
/// timestamp implementation.
fn now_rfc3339() -> String {
crate::engine::to_rfc3339(std::time::SystemTime::now())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_assigns_increasing_ids() {
let log = EventLog::new();
let a = log.push("info", "info", "a".into());
let b = log.push("threat", "critical", "b".into());
assert!(b > a);
}
#[test]
fn list_is_most_recent_first_and_capped() {
let log = EventLog::new();
for i in 0..5 {
log.push("info", "info", format!("e{i}"));
}
let all = log.list(0);
assert_eq!(all.len(), 5);
assert_eq!(all[0].message, "e4"); // newest first
let two = log.list(2);
assert_eq!(two.len(), 2);
assert_eq!(two[0].message, "e4");
}
#[test]
fn clear_returns_count() {
let log = EventLog::new();
log.push("info", "info", "x".into());
log.push("info", "info", "y".into());
assert_eq!(log.clear(), 2);
assert!(log.list(0).is_empty());
}
#[test]
fn capacity_bounds_the_buffer() {
let log = EventLog::new();
for i in 0..(CAPACITY + 10) {
log.push("info", "info", format!("e{i}"));
}
assert_eq!(log.list(0).len(), CAPACITY);
// Oldest ones were dropped; newest retained.
assert!(log.list(0)[0]
.message
.ends_with(&(CAPACITY + 9).to_string()));
}
}

View file

@ -1,25 +1,56 @@
//! `houndd` — the Hound engine.
//! `houndd` — the Hound Antivirus daemon.
//!
//! A tiny single-purpose daemon that exposes a scanning engine 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.
//! 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 engine itself is pluggable behind [`engine::ScanEngine`] — today
//! that's [`engine::ClamAvEngine`], tomorrow a native Rust engine. The
//! wire API doesn't know the difference; it only reports `Status.engine`.
//! ## The subsystems
//!
//! Current methods:
//! - `status` → engine health, signature-DB freshness
//! - `scan` → recursive scan of a path, per-file findings
//! - `update` → refresh the signature store
//! - **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 engine::{ScanEngine, ENGINE};
use hound_api::Response;
use hound_api::{Response, Settings};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader, Write};
@ -28,6 +59,17 @@ 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);
@ -37,11 +79,13 @@ fn main() -> Result<()> {
}
// 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 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.name()
engine::engine().name()
);
for stream in listener.incoming() {
@ -52,8 +96,9 @@ fn main() -> Result<()> {
continue;
}
};
let state = state.clone();
std::thread::spawn(move || {
if let Err(e) = handle_conn(stream) {
if let Err(e) = handle_conn(stream, state) {
eprintln!("connection error: {e}");
}
});
@ -61,16 +106,50 @@ fn main() -> Result<()> {
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) -> Result<()> {
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 req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?;
let result = dispatch(&req);
let result = dispatch(&req, &state);
let resp = match result {
Ok(value) => Response {
jsonrpc: "2.0".into(),
@ -103,10 +182,10 @@ fn writer_flush(reader: &mut BufReader<UnixStream>, bytes: &str) -> Result<()> {
Ok(())
}
fn dispatch(req: &hound_api::Request) -> Result<Value> {
fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
match req.method.as_str() {
"status" => Ok(serde_json::to_value(status())?),
"update" => Ok(serde_json::to_value(update()?)?),
"status" => Ok(serde_json::to_value(status(st)?)?),
"update" => Ok(serde_json::to_value(update(st)?)?),
"scan" => {
let path = req
.params
@ -122,48 +201,179 @@ fn dispatch(req: &hound_api::Request) -> Result<Value> {
.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() -> hound_api::Status {
let (present, db_summary, db) = ENGINE.probe();
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()
})
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 {
Ok(hound_api::Status {
daemon_version: DAEMON_VERSION.to_string(),
engine: ENGINE.name().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() -> Result<hound_api::UpdateResult> {
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
use hound_api::UpdateResult;
let (ok, command, combined) = ENGINE.update()?;
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(),
status: status(st)?,
})
}
@ -184,7 +394,7 @@ fn cap_tail(s: &str, max: usize) -> String {
}
fn scan(path: &str, recursive: bool) -> Result<hound_api::ScanResult> {
ENGINE.scan(path, recursive)
engine::engine().scan(path, recursive)
}
#[cfg(test)]
@ -193,8 +403,7 @@ mod tests {
#[test]
fn client_type_is_constructible() {
let _c: hound_api::Client =
hound_api::Client::new("/tmp/does-not-matter.sock".into());
let _c: hound_api::Client = hound_api::Client::new("/tmp/does-not-matter.sock".into());
}
#[test]
@ -204,4 +413,127 @@ mod tests {
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);
}
}

View file

@ -0,0 +1,227 @@
//! Quarantine — the vault where Hound keeps files it caught.
//!
//! A quarantined file is *moved* (not copied) into the store under a
//! generated name, so the original location no longer holds the threat.
//! Each entry has a JSON sidecar holding the original path, the signature
//! that caught it, the quarantine time, and whether it has been restored.
//!
//! Store layout:
//! $XDG_DATA_HOME/hound/quarantine/
//! <id> ← the file's bytes
//! <id>.meta.json ← QuarantineEntry metadata
//!
//! Restoring moves the bytes back to the original path (recreating parent
//! dirs if needed) and keeps the entry flagged `restored: true` so the UI
//! can show it was let back out. Removing is a hard delete.
use hound_api::QuarantineEntry;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Where the quarantine store lives for this user.
pub fn store_dir() -> PathBuf {
let data = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.is_empty());
let base = match data {
Some(d) => PathBuf::from(d),
None => {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".local").join("share")
}
};
base.join("hound").join("quarantine")
}
/// Thread-safe view over the on-disk quarantine store. The in-memory
/// cache keeps `list()` cheap; every mutation also rewrites the sidecar.
#[derive(Clone)]
pub struct Quarantine {
#[allow(dead_code)] // held so two Quarantine instances share a cache
cache: Arc<Mutex<Vec<QuarantineEntry>>>,
}
impl Default for Quarantine {
fn default() -> Self {
Self::new()
}
}
impl Quarantine {
pub fn new() -> Self {
Self {
cache: Arc::new(Mutex::new(Vec::new())),
}
}
/// List all quarantined entries, newest first.
pub fn list(&self) -> Vec<QuarantineEntry> {
let dir = store_dir();
let mut entries: Vec<QuarantineEntry> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(meta) = std::fs::read_to_string(&path) {
if let Ok(e) = serde_json::from_str::<QuarantineEntry>(&meta) {
entries.push(e);
}
}
}
}
}
entries.sort_by(|a, b| b.ts.cmp(&a.ts));
*self.cache.lock().unwrap() = entries.clone();
entries
}
/// Move `path` into the store. `virus` is the signature that caught it
/// (or "manual" when the user quarantines by hand). Returns the entry.
pub fn add(&self, path: &str, virus: &str) -> anyhow::Result<QuarantineEntry> {
let src = std::fs::canonicalize(path)
.map_err(|e| anyhow::anyhow!("no such file to quarantine {path}: {e}"))?;
let dir = store_dir();
std::fs::create_dir_all(&dir)?;
let id = make_id(&src);
let dest = dir.join(&id);
let meta_path = dir.join(format!("{id}.meta.json"));
// Move the bytes in.
std::fs::rename(&src, &dest)?;
let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
let entry = QuarantineEntry {
id: id.clone(),
original_path: src.to_string_lossy().to_string(),
quarantined_path: dest.to_string_lossy().to_string(),
virus: virus.to_string(),
size,
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
restored: false,
};
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
Ok(entry)
}
/// Restore a quarantined file to its original path.
pub fn restore(&self, id: &str) -> anyhow::Result<QuarantineEntry> {
let dir = store_dir();
let file = dir.join(id);
let meta_path = dir.join(format!("{id}.meta.json"));
let meta = std::fs::read_to_string(&meta_path)?;
let mut entry: QuarantineEntry = serde_json::from_str(&meta)?;
let dest = PathBuf::from(&entry.original_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&file, &dest)?;
entry.restored = true;
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
Ok(entry)
}
/// Hard-delete a quarantined file (bytes + sidecar). Returns bytes freed.
pub fn remove(&self, id: &str) -> anyhow::Result<u64> {
let dir = store_dir();
let file = dir.join(id);
let meta_path = dir.join(format!("{id}.meta.json"));
let bytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0);
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(&meta_path);
Ok(bytes)
}
/// Number of non-restored entries.
pub fn count(&self) -> u64 {
self.list().into_iter().filter(|e| !e.restored).count() as u64
}
}
/// Stable-ish id from the original path + a time component so two quarrantines
/// of the same file at different times get distinct ids.
fn make_id(path: &std::path::Path) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
// FNV-1a over path + nanos → 16 hex chars.
let input = format!("{}:{}", path.display(), now);
let mut hash: u64 = 0xcbf29ce484222325;
for b in input.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
/// Per-test data dir (tag it so tests never share a directory — one
/// test's cleanup must not race another's file writes).
fn tmp_data(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hound-qt-{}-{tag}", std::process::id()));
let _ = std::fs::create_dir_all(&d);
d
}
#[test]
fn add_then_list_then_remove() {
let data = tmp_data("addremove");
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_DATA_HOME", &data);
let src = data.join("victim.bin");
std::fs::write(&src, b"pay attention").unwrap();
let q = Quarantine::new();
let entry = q.add(src.to_str().unwrap(), "Test.Virus").unwrap();
assert_eq!(entry.virus, "Test.Virus");
assert!(!entry.restored);
// Original is gone, bytes are in the store.
assert!(!src.exists());
assert!(std::path::Path::new(&entry.quarantined_path).exists());
let list = q.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, entry.id);
let freed = q.remove(&entry.id).unwrap();
assert_eq!(freed, 13);
assert!(q.list().is_empty());
std::env::remove_var("XDG_DATA_HOME");
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn restore_puts_file_back() {
let data = tmp_data("restore");
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_DATA_HOME", &data);
let src = data.join("back.bin");
std::fs::write(&src, b"hello").unwrap();
let q = Quarantine::new();
let entry = q.add(src.to_str().unwrap(), "Manual").unwrap();
let restored = q.restore(&entry.id).unwrap();
assert!(restored.restored);
assert!(src.exists());
assert_eq!(std::fs::read(&src).unwrap(), b"hello");
std::env::remove_var("XDG_DATA_HOME");
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn ids_are_distinct() {
let a = make_id(std::path::Path::new("/tmp/x"));
std::thread::sleep(std::time::Duration::from_millis(1));
let b = make_id(std::path::Path::new("/tmp/x"));
assert_ne!(a, b);
}
}

View file

@ -0,0 +1,505 @@
//! Real-time interception.
//!
//! A background thread owns an inotify instance watching the configured
//! directories (recursively — we walk each dir and add a watch per
//! subdirectory, and add a watch when a new directory appears). For every
//! file-appearing / file-written event it:
//!
//! 1. increments the "files seen" counter,
//! 2. skips excluded paths,
//! 3. runs the engine on that one file,
//! 4. on a hit, either quarantines it (default) or just raises an alert,
//! 5. feeds a rolling 60-second window of write events into the
//! **ransomware heuristic** — a burst of writes past the configured
//! per-minute threshold raises a ransomware alarm.
//!
//! The engine, quarantine store, event log, and settings are all
//! `Arc`-shared, so the monitor and the RPC threads cooperate without
//! locking the world.
use hound_api::{QuarantineEntry, RealtimeStatus, Settings};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime};
use crate::engine::engine;
use crate::events::EventLog;
use crate::quarantine::Quarantine;
use crate::settings::SettingsStore;
use inotify::{EventMask, Inotify, WatchDescriptor, WatchMask};
const WINDOW: Duration = Duration::from_secs(60);
/// Mutable state the monitor writes and `status()` reads.
#[derive(Default)]
struct Counters {
files_seen: u64,
files_quarantined: u64,
last_event_at: Option<String>,
ransomware: String,
}
impl Counters {
fn new() -> Self {
Self {
ransomware: "calm".into(),
..Default::default()
}
}
}
/// The real-time monitor. Cheaply cloned (all state is shared).
pub struct RealtimeMonitor {
counters: Arc<Mutex<Counters>>,
started_at: Arc<Mutex<Option<SystemTime>>>,
running: Arc<AtomicBool>,
active: Arc<AtomicBool>,
handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
watch_dirs: Arc<Mutex<Vec<PathBuf>>>,
settings: SettingsStore,
quarantine: Quarantine,
events: EventLog,
}
impl Clone for RealtimeMonitor {
fn clone(&self) -> Self {
Self {
counters: Arc::clone(&self.counters),
started_at: Arc::clone(&self.started_at),
running: Arc::clone(&self.running),
active: Arc::clone(&self.active),
handle: Arc::clone(&self.handle),
watch_dirs: Arc::clone(&self.watch_dirs),
settings: self.settings.clone(),
quarantine: self.quarantine.clone(),
events: self.events.clone(),
}
}
}
impl RealtimeMonitor {
pub fn new(settings: SettingsStore, quarantine: Quarantine, events: EventLog) -> Self {
Self {
counters: Arc::new(Mutex::new(Counters::new())),
started_at: Arc::new(Mutex::new(None)),
running: Arc::new(AtomicBool::new(false)),
active: Arc::new(AtomicBool::new(false)),
handle: Arc::new(Mutex::new(None)),
watch_dirs: Arc::new(Mutex::new(Vec::new())),
settings,
quarantine,
events,
}
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::Relaxed)
}
pub fn is_active(&self) -> bool {
self.active.load(Ordering::Relaxed)
}
pub fn watch_dirs(&self) -> Vec<PathBuf> {
self.watch_dirs.lock().unwrap().clone()
}
pub fn status(&self) -> RealtimeStatus {
let s = self.settings.get();
let c = self.counters.lock().unwrap();
let started = *self.started_at.lock().unwrap();
let uptime = started
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
RealtimeStatus {
enabled: s.realtime_enabled && !s.paused,
watching: self
.watch_dirs()
.iter()
.map(|p| p.display().to_string())
.collect(),
files_seen: c.files_seen,
files_quarantined: c.files_quarantined,
last_event_at: c.last_event_at.clone(),
uptime_secs: uptime,
active: self.is_active(),
ransomware: c.ransomware.clone(),
}
}
/// Start the monitor thread if it isn't already running. Idempotent.
pub fn start(&self) -> Result<(), String> {
if self.is_running() {
return Ok(());
}
// Expand configured watch dirs (tildes) to real paths.
let s = self.settings.get();
let dirs: Vec<PathBuf> = s
.realtime_watch
.iter()
.filter_map(|d| resolve_dir(d))
.collect();
*self.watch_dirs.lock().unwrap() = dirs.clone();
if !self.is_active() {
*self.started_at.lock().unwrap() = Some(SystemTime::now());
}
self.running.store(true, Ordering::Relaxed);
let counters = Arc::clone(&self.counters);
let running = Arc::clone(&self.running);
let active = Arc::clone(&self.active);
let settings = self.settings.clone();
let quarantine = self.quarantine.clone();
let events = self.events.clone();
let watch_dirs = Arc::clone(&self.watch_dirs);
let handle = thread::Builder::new()
.name("houndd-realtime".into())
.spawn(move || {
run_monitor(
dirs,
&counters,
&running,
&active,
&settings,
&quarantine,
&events,
&watch_dirs,
)
})
.map_err(|e| e.to_string())?;
*self.handle.lock().unwrap() = Some(handle);
Ok(())
}
/// Stop the monitor thread (sets the running flag; the loop exits at
/// the next 1s tick).
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
let h = self.handle.lock().unwrap().take();
if let Some(h) = h {
let _ = h.join();
}
self.active.store(false, Ordering::Relaxed);
}
}
fn run_monitor(
initial_dirs: Vec<PathBuf>,
counters: &Arc<Mutex<Counters>>,
running: &Arc<AtomicBool>,
active: &Arc<AtomicBool>,
settings: &SettingsStore,
quarantine: &Quarantine,
events: &EventLog,
watch_dirs: &Arc<Mutex<Vec<PathBuf>>>,
) {
let Ok(mut inotify) = Inotify::init() else {
eprintln!("realtime: inotify init failed — monitor idle");
return;
};
// inotify 0.10 has no wd→path lookup, so we keep our own map.
let mut wd_map: HashMap<WatchDescriptor, PathBuf> = HashMap::new();
let mask = WatchMask::CREATE | WatchMask::CLOSE_WRITE | WatchMask::MOVED_TO;
for d in &initial_dirs {
add_recursive_watches(&mut inotify, d, mask, &mut wd_map);
}
active.store(true, Ordering::Relaxed);
eprintln!(
"realtime: watching {} dir(s), {} watch(es)",
initial_dirs.len(),
wd_map.len()
);
let mut buf = [0u8; 16_384];
let mut write_window: VecDeque<SystemTime> = VecDeque::new();
let engine = engine();
while running.load(Ordering::Relaxed) {
let evts = match inotify.read_events(&mut buf) {
Ok(iter) => iter.collect::<Vec<_>>(),
// Non-blocking fd: nothing queued right now — sleep and retry.
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(100));
continue;
}
Err(e) => {
eprintln!("realtime: inotify read error: {e} — backing off");
thread::sleep(Duration::from_millis(500));
continue;
}
};
if evts.is_empty() {
continue;
}
let now = SystemTime::now();
for ev in &evts {
let (is_write, is_new_dir, path) = match classify(ev, &wd_map) {
Some(v) => v,
None => continue,
};
let mut c = counters.lock().unwrap();
if is_new_dir {
// A new directory inside a watched dir → watch it too.
if let Ok(wd) = inotify.watches().add(&path, mask) {
wd_map.insert(wd, path.clone());
watch_dirs.lock().unwrap().push(path.clone());
}
continue;
}
if !is_write {
continue;
}
// File write/move event.
c.files_seen += 1;
c.last_event_at = Some(crate::engine::to_rfc3339(now));
drop(c);
let s = settings.get();
if s.paused || !s.realtime_enabled {
continue;
}
// Ransomware write-burst heuristic.
write_window.push_back(now);
while let Some(front) = write_window.front() {
if now.duration_since(*front).unwrap_or_default() > WINDOW {
write_window.pop_front();
} else {
break;
}
}
if s.ransomware_guard && (write_window.len() as u32) >= s.ransomware_threshold_per_min {
let mut c = counters.lock().unwrap();
if c.ransomware != "alarm" {
c.ransomware = "alarm".into();
}
drop(c);
events.push(
"ransomware",
"critical",
format!(
"write burst: {} file writes in 60s (threshold {}) — possible ransomware",
write_window.len(),
s.ransomware_threshold_per_min
),
);
}
if excluded(&path, &s.exclude_paths) {
continue;
}
// Scan this single file with the engine.
match engine.scan(path.to_str().unwrap_or(""), false) {
Ok(r) if !r.is_clean() => {
let found = r.found.first().cloned();
let virus = found
.as_ref()
.map(|f| f.virus.clone())
.unwrap_or_else(|| "unknown".into());
if s.on_detect == "quarantine" {
match quarantine.add(path.to_str().unwrap_or(""), &virus) {
Ok(entry) => {
let mut c = counters.lock().unwrap();
c.files_quarantined += 1;
drop(c);
events.push(
"quarantine",
"critical",
format!(
"real-time: quarantined {} ({}) as {:?}",
entry.original_path, virus, entry.id
),
);
}
Err(e) => {
events.push(
"realtime",
"warn",
format!("real-time: failed to quarantine {path:?}: {e}"),
);
}
}
} else {
events.push(
"threat",
"critical",
format!("real-time: {} found {} (alert-only)", virus, path.display()),
);
}
}
_ => {}
}
}
}
active.store(false, Ordering::Relaxed);
}
/// Walk `root` and add an inotify watch to it and every subdirectory,
/// recording each watch descriptor for later path resolution.
fn add_recursive_watches(
inotify: &mut Inotify,
root: &Path,
mask: WatchMask,
wd_map: &mut HashMap<WatchDescriptor, PathBuf>,
) {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if let Ok(wd) = inotify.watches().add(&dir, mask) {
wd_map.insert(wd, dir.clone());
}
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
stack.push(p);
}
}
}
}
}
/// Decide what an inotify event means for us.
/// Returns (is_file_write_event, is_new_dir, resolved_path).
fn classify<S: AsRef<std::ffi::OsStr>>(
ev: &inotify::Event<S>,
wd_map: &HashMap<WatchDescriptor, PathBuf>,
) -> Option<(bool, bool, PathBuf)> {
let base = wd_map.get(&ev.wd)?.clone();
let mut path = base.clone();
if let Some(name) = &ev.name {
let name = name.as_ref().to_string_lossy();
if !name.is_empty() {
path = path.join(name.as_ref());
}
}
let write = ev
.mask
.intersects(EventMask::MOVED_TO | EventMask::CREATE | EventMask::CLOSE_WRITE);
// A child path that resolves to a directory is a new dir to watch.
let is_new_dir = path != base && path.is_dir();
Some((write, is_new_dir, path))
}
/// Expand a possibly-tilde path to a real directory, or None if it doesn't
/// resolve to an existing dir.
pub fn resolve_dir(spec: &str) -> Option<PathBuf> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
let path = if let Some(rest) = spec.strip_prefix("~/") {
let home = std::env::var("HOME").ok()?;
PathBuf::from(home).join(rest)
} else {
PathBuf::from(spec)
};
let canonical = path.canonicalize().ok()?;
if canonical.is_dir() {
Some(canonical)
} else {
None
}
}
/// True when `path` falls under any exclude entry (prefix match).
pub fn excluded(path: &Path, excludes: &[String]) -> bool {
let s = path.to_string_lossy();
excludes.iter().any(|e| {
let e = e.trim();
if e.is_empty() {
return false;
}
if e.ends_with('/') {
s.starts_with(e)
} else {
s == e || s.starts_with(&format!("{e}/"))
}
})
}
/// Convenience for the RPC layer: quarantine one file and log it.
pub fn quarantine_and_log(
quarantine: &Quarantine,
events: &EventLog,
path: &str,
virus: &str,
) -> anyhow::Result<QuarantineEntry> {
let entry = quarantine.add(path, virus)?;
events.push(
"quarantine",
"critical",
format!(
"quarantined {} ({}) as {:?}",
entry.original_path, virus, entry.id
),
);
Ok(entry)
}
/// Re-exported so callers don't reach into `settings` directly for the type.
#[allow(dead_code)]
pub(crate) fn settings_type() -> Settings {
Settings::default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_dir_expands_tilde() {
let home = std::env::var("HOME").unwrap();
let p = resolve_dir("~/").unwrap();
assert!(p.starts_with(PathBuf::from(&home)));
}
#[test]
fn resolve_dir_missing_is_none() {
assert!(resolve_dir("/no/such/dir/here").is_none());
}
#[test]
fn excluded_prefix_match() {
let p = PathBuf::from("/proc/self/1");
assert!(excluded(&p, &["/proc".into()]));
assert!(excluded(&p, &["/proc/".into()]));
assert!(!excluded(&p, &["/home".into()]));
}
#[test]
fn monitor_start_stop_is_clean() {
let dir = std::env::temp_dir().join(format!("hound-rt-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
// Isolate settings + quarantine data.
let cfg = std::env::temp_dir().join(format!("hound-rt-cfg-{}", std::process::id()));
let data = std::env::temp_dir().join(format!("hound-rt-data-{}", std::process::id()));
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_CONFIG_HOME", &cfg);
std::env::set_var("XDG_DATA_HOME", &data);
let settings = SettingsStore::load();
let events = EventLog::new();
let quarantine = Quarantine::new();
let mon = RealtimeMonitor::new(settings, quarantine, events);
mon.start().unwrap();
assert!(mon.is_running());
std::thread::sleep(Duration::from_millis(150));
let st = mon.status();
assert!(st.uptime_secs < 5);
mon.stop();
assert!(!mon.is_running());
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("XDG_DATA_HOME");
for d in [&dir, &cfg, &data] {
let _ = std::fs::remove_dir_all(d);
}
}
}

View file

@ -0,0 +1,335 @@
//! Rootkit detection.
//!
//! Without a kernel module we can't do full DKM/rootkit detection, but we
//! can run a set of userspace heuristics that catch the *common* rootkit
//! tricks. Each check is a "finding" with a severity; the aggregate scan
//! reports every finding plus a pass/fail verdict.
//!
//! Checks performed (each individually testable):
//!
//! 1. **Hidden processes** — every `pid` in `/proc` must be readable.
//! Rootkits that `hide` a process by making `/proc/<pid>` unreadable
//! (or via a `hidepid` mount) surface here as a "unreadable pid".
//! 2. **Hidden files** — every entry reported by a raw `readdir` of a
//! watched dir must be visible to `fs::read_dir`'s metadata probe.
//! A file that exists in the dir listing but whose `stat` fails is a
//! strong signal (classic `lsof`-vs-`ls` discrepancy).
//! 3. **Writable system dirs** — `/etc`, `/bin`, `/lib`, `/lib64`,
//! `/sbin`, `/usr/bin`, `/usr/lib`, `/usr/lib64`, `/boot` should be
//! write-protected for non-root. A writable system dir is where
//! rootkits drop modified binaries.
//! 4. **Setuid/setgid anomalies** — collect setuid binaries under
//! `/usr` and flag ones we don't expect (a plain list, easy to extend).
//!
//! These run without root for the common case; root gives stronger
//! signals (e.g. the hidden-pid check is only meaningful when we can
//! actually read `/proc`).
use hound_api::{RootkitFinding, RootkitScan};
use std::path::Path;
/// The full rootkit scan. `watch_dirs` are the dirs to run the
/// hidden-file check against (typically the same set the real-time
/// monitor watches, so a dropped rootkit file gets caught on both
/// paths).
pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
let mut findings: Vec<RootkitFinding> = Vec::new();
findings.extend(hidden_pids());
findings.extend(hidden_files(watch_dirs));
findings.extend(writable_system_dirs());
findings.extend(setuid_anomalies());
let critical = findings.iter().filter(|f| f.severity == "critical").count() as u32;
let warn = findings.iter().filter(|f| f.severity == "warn").count() as u32;
let info = findings.iter().filter(|f| f.severity == "info").count() as u32;
let clean = critical == 0 && warn == 0;
let verdict = if clean {
"clean".into()
} else {
format!("{critical} critical, {warn} warning(s)")
};
RootkitScan {
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
verdict,
critical,
warn,
info,
findings,
}
}
/// Check 1: any pid in /proc that is unreadable.
fn hidden_pids() -> Vec<RootkitFinding> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir("/proc") else {
out.push(finding(
"info",
"proc",
"/proc not readable — running without enough privilege for a hidden-pid check"
.to_string(),
));
return out;
};
for e in rd.flatten() {
let name = e.file_name();
let s = match name.to_str() {
Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s.to_string(),
_ => continue,
};
let p = Path::new("/proc").join(&s);
// A rootkit hiding a process makes /proc/<pid> unreadable.
if let Ok(md) = e.metadata() {
if !md.is_dir() {
continue;
}
}
// Read the first byte of /proc/<pid>/comm — a hidden process
// often makes this fail with EACCES/EAGAIN.
let comm = p.join("comm");
if std::fs::read(&comm).is_err() {
out.push(finding(
"warn",
"hidden_pid",
format!("/proc/{s} exists but is unreadable — process may be hidden"),
));
}
}
out
}
/// Check 2: files in a watched dir that readdir sees but stat can't resolve.
fn hidden_files(watch_dirs: &[String]) -> Vec<RootkitFinding> {
let mut out = Vec::new();
for d in watch_dirs {
let path = Path::new(d);
let Ok(rd) = std::fs::read_dir(path) else {
continue;
};
for e in rd.flatten() {
let p = e.path();
// If readdir gave us the entry but stat-by-path fails, that's
// a discrepancy. We re-stat by full path (not the DirEntry's
// already-cached metadata) to detect this class of rootkit.
if std::fs::metadata(&p).is_err() {
out.push(finding(
"warn",
"hidden_file",
format!(
"{} visible in dir listing but stat-by-path fails",
p.display()
),
));
}
}
}
out
}
/// Check 3: writable system dirs.
fn writable_system_dirs() -> Vec<RootkitFinding> {
const SYSTEM_DIRS: &[&str] = &[
"/etc",
"/bin",
"/lib",
"/lib64",
"/sbin",
"/usr/bin",
"/usr/lib",
"/usr/lib64",
"/boot",
];
let mut out = Vec::new();
// Determine our effective uid so we know whether "writable" is
// actually a concern (if we're root, everything is writable).
let uid = current_uid();
let root = uid == 0;
for d in SYSTEM_DIRS {
let p = Path::new(d);
if !p.is_dir() {
continue;
}
// A dir is writable by *someone other than us* if:
// - group/other write bits are set, OR
// - the owning group/other is writable and we're not the owner.
// We approximate with the simple heuristic: group or other write
// bit set. This is intentionally conservative — it flags real
// problems and accepts a few false positives on permissive setups.
let Ok(md) = std::fs::metadata(p) else {
continue;
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = md.mode();
let group_w = mode & 0o020 != 0;
let other_w = mode & 0o002 != 0;
if (group_w || other_w) && !root {
out.push(finding(
"warn",
"writable_system_dir",
format!("{d} is group/other writable (mode {mode:o})"),
));
}
}
#[cfg(not(unix))]
{
let _ = (uid, md);
}
}
out
}
/// Check 4: setuid/setgid binaries under /usr that look unusual.
fn setuid_anomalies() -> Vec<RootkitFinding> {
let mut out = Vec::new();
let expected = [
"sudo",
"sudoedit",
"su",
"sg",
"newgrp",
"pkexec",
"doas",
"at",
"crontab",
"chfn",
"chsh",
"chage",
"chgpasswd",
"passwd",
"gpasswd",
"expiry",
"unix_chkpwd",
"pam_extrausers_chkpwd",
"pam_timestamp_check",
"mount",
"umount",
"mount.cifs",
"mount.smb3",
"mount.nfs",
"mount.nfs4",
"mount.ecryptfs_private",
"umount.ecryptfs_private",
"fusermount",
"fusermount3",
"newuidmap",
"newgidmap",
"ping",
"ping6",
"ip",
"ip6",
"Xorg",
"ssh-agent",
"gpg-agent",
"dbus-daemon-launch-helper",
"polkit-agent-helper-1",
"dotlockfile",
"locate",
"plocate",
"pppd",
"postdrop",
"postqueue",
"newsyslog",
"mullvad-exclude",
"screen",
"tmux",
];
let roots = ["/usr/bin", "/usr/sbin", "/usr/local/bin"];
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for r in roots {
let Ok(rd) = std::fs::read_dir(r) else {
continue;
};
for e in rd.flatten() {
let p = e.path();
if !p.is_file() {
continue;
}
let Ok(md) = std::fs::metadata(&p) else {
continue;
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = md.mode();
let setuid = mode & 0o4000 != 0;
let setgid = mode & 0o2000 != 0;
if !setuid && !setgid {
continue;
}
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
let Some(name) = name else { continue };
if seen.insert(name.clone()) && !expected.iter().any(|x| *x == name) {
out.push(finding(
"info",
"setuid_anomaly",
format!("unexpected setuid/setgid binary: {}", p.display()),
));
}
}
#[cfg(not(unix))]
{
let _ = md;
}
}
}
out
}
fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
RootkitFinding {
check: kind.to_string(),
severity: sev.to_string(),
detail,
}
}
fn current_uid() -> u32 {
// /proc/self is the portable, no-dependency way on Linux.
if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
for line in s.lines() {
if let Some(v) = line.strip_prefix("Uid:") {
if let Some(first) = v.split_whitespace().next() {
if let Ok(uid) = first.parse() {
return uid;
}
}
}
}
}
u32::MAX
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_shape_is_consistent() {
let scan = run_scan(&[]);
let total = scan.findings.len() as u32;
assert_eq!(scan.critical + scan.warn + scan.info, total);
assert!(!scan.verdict.is_empty());
assert!(scan.ts.len() >= 10);
}
#[test]
fn hidden_file_detects_unstatable() {
// Create a real dir and a real file — should produce no finding.
let dir = std::env::temp_dir().join(format!("hound-rk-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let f = dir.join("ok.txt");
std::fs::write(&f, b"hi").unwrap();
let out = hidden_files(&[dir.to_string_lossy().to_string()]);
assert!(out.is_empty(), "unexpected finding: {out:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn current_uid_is_sane() {
let uid = current_uid();
assert!(uid < 10_000);
}
}

View file

@ -0,0 +1,151 @@
//! Settings persistence.
//!
//! Stored as JSON at `$XDG_CONFIG_HOME/hound/settings.json` (falling back
//! to `~/.config/hound/`). The daemon holds a live copy in
//! [`crate::DaemonState`]; every mutation here is also written to disk so
//! the settings survive a daemon restart.
//!
//! Load is tolerant: a missing or corrupt file yields `Settings::default()`
//! rather than crashing the daemon over a settings knob.
use hound_api::Settings;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
/// Where the settings file lives for this user.
pub fn settings_path() -> PathBuf {
let cfg = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty());
let base = match cfg {
Some(c) => PathBuf::from(c),
None => {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".config")
}
};
base.join("hound").join("settings.json")
}
/// A live, persisted settings store. `Arc` so it can be shared between the
/// RPC threads and the realtime monitor without copying the struct.
pub struct SettingsStore {
inner: Arc<Mutex<Settings>>,
}
impl Clone for SettingsStore {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl SettingsStore {
/// Load from disk, or start from defaults and persist them so the file
/// exists for the user to inspect/edit.
pub fn load() -> Self {
let path = settings_path();
let loaded = std::fs::read_to_string(&path).ok().and_then(|c| {
match serde_json::from_str::<Settings>(&c) {
Ok(s) => Some(s),
Err(_) => {
eprintln!("settings: corrupt {}, using defaults", path.display());
None
}
}
});
let settings = loaded.unwrap_or_else(|| {
// New install: persist defaults so the file is visible/editable.
let d = Settings::default();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, serde_json::to_string_pretty(&d).unwrap());
d
});
Self {
inner: Arc::new(Mutex::new(settings)),
}
}
/// Clone of the current settings (for reading in handlers).
pub fn get(&self) -> Settings {
self.inner.lock().unwrap().clone()
}
/// Replace the live settings and persist to disk.
pub fn set(&self, next: &Settings) -> Result<(), String> {
let path = settings_path();
let json = serde_json::to_string_pretty(next).map_err(|e| e.to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&path, json).map_err(|e| e.to_string())?;
*self.inner.lock().unwrap() = next.clone();
Ok(())
}
/// Swap in a specific field by closure, persisting the result. Used by
/// `realtime.set_enabled` and `settings.set` without a read-modify race.
pub fn mutate(&self, f: impl FnOnce(&mut Settings)) -> Settings {
let mut guard = self.inner.lock().unwrap();
f(&mut guard);
let snapshot = guard.clone();
if let Some(parent) = settings_path().parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(
settings_path(),
serde_json::to_string_pretty(&snapshot).unwrap(),
);
snapshot
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_have_sane_values() {
let s = Settings::default();
assert!(s.realtime_enabled);
assert!(s.ransomware_guard);
assert!(!s.paused);
assert!(!s.exclude_paths.is_empty());
assert_eq!(s.on_detect, "quarantine");
}
#[test]
fn roundtrips_through_json() {
let s = Settings::default();
let j = serde_json::to_string(&s).unwrap();
let back: Settings = serde_json::from_str(&j).unwrap();
assert_eq!(
back.ransomware_threshold_per_min,
s.ransomware_threshold_per_min
);
assert_eq!(back.realtime_watch, s.realtime_watch);
}
#[test]
fn store_set_get() {
// Isolate the config dir for this test.
let dir = std::env::temp_dir().join(format!("hound-settings-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_CONFIG_HOME", &dir);
let store = SettingsStore::load();
let mut s = store.get();
s.ransomware_threshold_per_min = 7;
s.on_detect = "alert".into();
store.set(&s).unwrap();
let reloaded = SettingsStore::load();
assert_eq!(reloaded.get().ransomware_threshold_per_min, 7);
assert_eq!(reloaded.get().on_detect, "alert");
std::env::remove_var("XDG_CONFIG_HOME");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,22 @@
//! Shared test utilities.
//!
//! One crate-wide lock for tests that mutate process environment variables
//! (XDG_*, HOUNDD_*). The daemon resolves its config/data paths from the
//! environment at call time, so two tests running on different `cargo test`
//! threads and setting the same variable will clobber each other's store
//! locations. Tests that touch env vars must hold this lock for the whole
//! test body.
/// Crate-wide mutex serializing process-environment mutations in tests.
pub fn env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
/// Lock, tolerating a poisoned mutex (a previous test panicked while
/// holding it) so one failure doesn't cascade into the rest of the suite.
pub fn locked() -> std::sync::MutexGuard<'static, ()> {
env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

514
gui/dist/app.js vendored Normal file
View file

@ -0,0 +1,514 @@
// Hound Antivirus — webview front-end.
// Thin view over houndd via Tauri commands; 6-tab layout.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
const $ = (id) => document.getElementById(id);
const ICONS = {
protected: "state-protected-48.png",
scanning: "state-scanning-48.png",
threat: "state-threat-48.png",
paused: "state-paused-48.png",
};
const TITLES = {
protected: "Protected",
scanning: "Scanning…",
threat: "Threat Detected",
paused: "Paused",
};
const SUBS = {
protected: "Your system looks healthy.",
scanning: "Hound is working the queue.",
threat: "We found something that should not be there.",
paused: "Real-time protection is off.",
};
let currentState = "protected";
let busy = false;
let paused = false;
// ── Tabs ───────────────────────────────────────────────────────────
const LOADERS = {
quarantine: loadQuarantine,
realtime: loadRealtime,
rootkit: null, // on demand
alerts: loadAlerts,
settings: loadSettings,
};
function switchTab(name) {
document.querySelectorAll(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.tab === name));
document.querySelectorAll(".tab-panel").forEach((p) =>
p.classList.toggle("active", p.id === "panel-" + name));
const fn = LOADERS[name];
if (fn) fn().catch(() => {});
}
document.querySelectorAll(".tab").forEach((t) =>
t.addEventListener("click", () => switchTab(t.dataset.tab)));
// ── State rendering (hero + tray) ─────────────────────────────────
function setState(state) {
if (!ICONS[state]) state = "protected";
currentState = state;
$("hero").dataset.state = state;
$("shield-icon").src = ICONS[state];
$("hero-title").textContent = TITLES[state];
$("hero-sub").textContent = SUBS[state];
invoke("set_state", { state }).catch(() => {});
}
function setEngineDot(cls, label) {
$("engine-dot").className = "engine-dot " + cls;
$("engine-label").textContent = label;
}
function fmtDbAge(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return `${days} days ago`;
const mo = Math.floor(days / 30);
return `${mo} month${mo > 1 ? "s" : ""} ago`;
}
function renderStatus(st) {
setEngineDot(st.engine_present ? "ok" : "bad",
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
$("pill-os").textContent = "OS: " + (st.os || "—");
$("pill-engine").textContent = "engine: " + (st.engine || "—");
const pill = $("pill-db");
if (st.db) {
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
const staleDays = (Date.now() - new Date(st.db.updated_at).getTime()) / 86400000;
pill.classList.toggle("stale", staleDays > 3);
$("foot-ver").textContent = `houndd ${st.daemon_version} · ${st.db.file}`;
} else {
pill.textContent = "signatures: none";
pill.classList.add("stale");
$("foot-ver").textContent = `houndd ${st.daemon_version}`;
}
}
// ── Scans ──────────────────────────────────────────────────────────
async function doScan(path) {
if (busy) return;
busy = true;
setButtons(true);
$("scan-panel").classList.remove("hidden");
$("scan-panel").querySelector(".progress").classList.add("indeterminate");
$("scan-title").textContent = "Scanning…";
$("scan-target").textContent = path;
setState("scanning");
try {
const r = await invoke("scan", { path, recursive: true });
renderScanResult(r, path);
} catch (e) {
$("scan-note").textContent = "Scan failed: " + String(e);
setState(paused ? "paused" : "protected");
} finally {
busy = false;
setButtons(false);
$("scan-panel").querySelector(".progress").classList.remove("indeterminate");
}
}
function renderScanResult(r, path) {
const clean = r.infected === 0;
setState(clean ? (paused ? "paused" : "protected") : "threat");
$("scan-title").textContent = clean
? "Scan complete — clean"
: `Scan complete — ${r.infected} threat${r.infected > 1 ? "s" : ""}`;
$("scan-note").textContent =
`${r.scanned.toLocaleString()} files scanned · ${r.clean.toLocaleString()} clean · ${r.infected.toLocaleString()} infected`;
$("progress-bar").style.width = "100%";
$("results-meta").textContent = `${r.scanned} files · ${path}`;
const body = $("results-body");
body.innerHTML = "";
if (clean) {
body.innerHTML =
`<div class="result-row clean">
<span class="sig"> All clear</span>
<span class="path">${r.scanned.toLocaleString()} files scanned, nothing flagged</span>
<span></span>
</div>`;
} else {
for (const f of r.found.slice(0, 50)) {
const div = document.createElement("div");
div.className = "result-row infected";
div.innerHTML =
`<span class="sig">✘ ${escapeHtml(f.virus)}</span>
<span class="path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</span>
<span></span>`;
body.appendChild(div);
}
if (r.found.length > 50) {
const more = document.createElement("div");
more.className = "muted";
more.style.padding = "4px 2px";
more.textContent = `…and ${r.found.length - 50} more`;
body.appendChild(more);
}
}
}
// ── Signature update ───────────────────────────────────────────────
async function doUpdate() {
if (busy) return;
busy = true;
setButtons(true);
$("update-panel").classList.remove("hidden");
const log = $("update-log");
log.className = "log";
log.textContent = "Running freshclam — this can take a minute…\n";
try {
const u = await invoke("update");
log.textContent = u.output.trim();
log.className = "log " + (u.ok ? "ok" : "fail");
if (u.status) renderStatus(u.status);
setState(paused ? "paused" : "protected");
} catch (e) {
log.textContent = "Update failed: " + String(e);
log.className = "log fail";
} finally {
busy = false;
setButtons(false);
}
}
// ── Quarantine tab ─────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let v = bytes, i = -1;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v < 10 ? 1 : 0) + " " + units[i];
}
function fmtTime(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
return t.toLocaleString();
}
function setQuarantineBadge(n) {
const b = $("badge-quarantine");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadQuarantine() {
try {
const list = await invoke("quarantine_list");
const body = $("qt-body");
body.innerHTML = "";
setQuarantineBadge(list.length);
$("qt-count").textContent = list.length
? `${list.length} item${list.length > 1 ? "s" : ""} held`
: "vault is empty";
if (!list.length) {
body.innerHTML = '<p class="muted empty">Nothing in quarantine. Your clean system thanks you.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "qt-row" + (e.restored ? " restored" : "");
row.innerHTML =
`<span class="qt-virus">${escapeHtml(e.virus)}</span>
<span>
<span class="qt-path" title="${escapeHtml(e.original_path)}">${escapeHtml(e.original_path)}</span><br/>
<span class="qt-meta">${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""}</span>
</span>
<span class="qt-actions"></span>`;
const actions = row.querySelector(".qt-actions");
if (!e.restored) {
const rb = document.createElement("button");
rb.className = "btn small";
rb.textContent = "Restore";
rb.onclick = async () => {
rb.disabled = true;
try { await invoke("quarantine_restore", { id: e.id }); loadQuarantine(); }
catch (err) { rb.disabled = false; alert("Restore failed: " + err); }
};
actions.appendChild(rb);
}
const xb = document.createElement("button");
xb.className = "btn small danger";
xb.textContent = "Remove";
xb.onclick = async () => {
if (!confirm(`Remove ${e.virus} from the vault? The bytes are deleted for good.`)) return;
xb.disabled = true;
try { await invoke("quarantine_remove", { id: e.id }); loadQuarantine(); }
catch (err) { xb.disabled = false; alert("Remove failed: " + err); }
};
actions.appendChild(xb);
body.appendChild(row);
}
} catch (e) {
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
}
}
// ── Realtime tab ───────────────────────────────────────────────────
function fmtUptime(secs) {
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
async function loadRealtime() {
try {
const rt = await invoke("realtime_status");
const st = await invoke("settings");
$("rt-toggle").checked = rt.enabled;
$("rt-enabled-label").textContent = rt.enabled ? "monitor running" : "monitor off";
$("rt-seen").textContent = rt.files_seen.toLocaleString();
$("rt-quar").textContent = rt.files_quarantined.toLocaleString();
$("rt-uptime").textContent = rt.active ? fmtUptime(rt.uptime_secs) : "—";
const rw = $("rt-ransom");
rw.textContent = rt.ransomware;
rw.className = "stat-val " +
(rt.ransomware === "alarm" ? "alarm" : rt.ransomware === "watching" ? "watching" : "calm");
$("rt-watch").textContent = rt.watching.join(", ") || "—";
$("rt-last").textContent = rt.last_event_at ? fmtTime(rt.last_event_at) : "none yet";
$("rt-action").textContent = st.on_detect;
const wl = $("rt-watch-list");
wl.innerHTML = "";
for (const dir of st.realtime_watch) {
const chip = document.createElement("span");
chip.className = "watch-chip";
chip.textContent = dir;
wl.appendChild(chip);
}
} catch (e) {
$("rt-enabled-label").textContent = "error: " + String(e);
}
}
// ── Rootkit tab ────────────────────────────────────────────────────
async function runRootkit() {
const btn = $("btn-rootkit");
btn.disabled = true;
btn.textContent = "Scanning…";
$("rootkit-banner").classList.add("hidden");
$("rootkit-body").innerHTML = '<p class="muted empty">Checking setuid bits, deleted executables, world-writable binaries…</p>';
try {
const r = await invoke("rootkit_scan");
const banner = $("rootkit-banner");
const dirty = r.critical + r.warn > 0;
banner.textContent = dirty ? `${r.verdict}` : `${r.verdict}${r.info} informational note${r.info === 1 ? "" : "s"}`;
banner.className = "verdict " + (dirty ? "dirty" : "clean");
banner.classList.remove("hidden");
const body = $("rootkit-body");
body.innerHTML = "";
if (!r.findings.length) {
body.innerHTML = '<p class="muted empty">No findings at all — squeaky clean.</p>';
return;
}
const rank = { critical: 0, warn: 1, info: 2 };
const sorted = [...r.findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3));
for (const f of sorted) {
const row = document.createElement("div");
row.className = "finding-row";
row.innerHTML =
`<span class="sev ${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span>
<span class="check">${escapeHtml(f.check)}</span>
<span class="detail">${escapeHtml(f.detail)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(String(e))}</p>`;
} finally {
btn.disabled = false;
btn.textContent = "Run Scan";
}
}
// ── Alerts tab ─────────────────────────────────────────────────────
function setAlertsBadge(n) {
const b = $("badge-alerts");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadAlerts() {
try {
const list = await invoke("events", { limit: 200 });
$("alerts-count").textContent = list.length ? `${list.length} events` : "";
setAlertsBadge(list.filter((e) => e.severity === "critical").length);
const body = $("alerts-body");
body.innerHTML = "";
if (!list.length) {
body.innerHTML = '<p class="muted empty">No events yet — the dog hasnt barked.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "event-row";
row.innerHTML =
`<span class="ev-sev ${escapeHtml(e.severity)}">${escapeHtml(e.severity)}</span>
<span class="ev-kind">${escapeHtml(e.kind)}</span>
<span class="ev-msg">${escapeHtml(e.message)}</span>
<span class="ev-ts">${fmtTime(e.ts)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
}
}
// ── Settings tab ───────────────────────────────────────────────────
let settingsCache = null;
async function loadSettings() {
try {
const s = await invoke("settings");
settingsCache = s;
$("set-paused").checked = s.paused;
$("set-autoupdate").checked = s.auto_update_signatures;
$("set-notify").checked = s.notify_desktop;
$("set-realtime").checked = s.realtime_enabled;
$("set-watch").value = (s.realtime_watch || []).join("\n");
$("set-ondetect").value = s.on_detect;
$("set-maxsize").value = s.max_file_size_mb;
$("set-excludes").value = (s.exclude_paths || []).join("\n");
$("set-recursive").checked = s.recursive_default;
$("set-ransom").checked = s.ransomware_guard;
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
$("set-rootkit").checked = s.rootkit_enabled;
} catch (e) {
$("settings-msg").textContent = "Failed to load: " + String(e);
}
}
async function saveSettings() {
if (!settingsCache) return;
const s = settingsCache;
s.paused = $("set-paused").checked;
s.auto_update_signatures = $("set-autoupdate").checked;
s.notify_desktop = $("set-notify").checked;
s.realtime_enabled = $("set-realtime").checked;
s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.on_detect = $("set-ondetect").value;
s.max_file_size_mb = Math.max(1, parseInt($("set-maxsize").value, 10) || 0);
s.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.recursive_default = $("set-recursive").checked;
s.ransomware_guard = $("set-ransom").checked;
s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100);
s.rootkit_enabled = $("set-rootkit").checked;
try {
const saved = await invoke("set_settings", { s });
paused = saved.paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
$("settings-msg").textContent = "Saved ✓";
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
loadRealtime().catch(() => {});
} catch (e) {
$("settings-msg").textContent = "Save failed: " + String(e);
}
}
// ── Wiring ─────────────────────────────────────────────────────────
function setButtons(disabled) {
for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"])
$(id).disabled = disabled;
}
function escapeHtml(s) {
return String(s).replace(/[&<>\"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
async function pickFolder() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const dir = await open({ directory: true, multiple: false, title: "Choose a folder to scan" });
if (dir) doScan(dir);
} catch {
const p = prompt("Folder to scan:");
if (p) doScan(p);
}
}
function togglePause() {
paused = !paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
setState(paused ? "paused" : "protected");
}
$("btn-scan-home").addEventListener("click", () => doScan("~"));
$("btn-scan-custom").addEventListener("click", pickFolder);
$("btn-update").addEventListener("click", doUpdate);
$("btn-pause").addEventListener("click", togglePause);
$("btn-qt-refresh").addEventListener("click", () => loadQuarantine());
$("btn-alerts-refresh").addEventListener("click", () => loadAlerts());
$("btn-rootkit").addEventListener("click", runRootkit);
$("btn-settings-save").addEventListener("click", saveSettings);
$("btn-qt-add").addEventListener("click", async () => {
const path = $("qt-add-path").value.trim();
const virus = $("qt-add-virus").value.trim() || "manual";
if (!path) return alert("Enter a file path to quarantine.");
try {
await invoke("quarantine_add", { path, virus });
$("qt-add-path").value = "";
loadQuarantine();
} catch (e) { alert("Quarantine failed: " + e); }
});
$("btn-alerts-clear").addEventListener("click", async () => {
if (!confirm("Clear the whole event log?")) return;
try {
const n = await invoke("clear_events");
$("alerts-count").textContent = `${n} event${n === 1 ? "" : "s"} cleared`;
loadAlerts();
} catch (e) { alert("Clear failed: " + e); }
});
$("rt-toggle").addEventListener("change", async (ev) => {
try {
await invoke("realtime_set_enabled", { enabled: ev.target.checked });
loadRealtime();
} catch (e) {
ev.target.checked = !ev.target.checked;
alert("Toggle failed: " + e);
}
});
// Tray menu events (Scan Home / Scan Downloads / Update).
listen("tray-event", (e) => {
const p = e.payload;
if (p?.action === "scan" && p.path) {
switchTab("protection");
doScan(p.path);
} else if (p?.action === "update") {
switchTab("protection");
doUpdate();
}
});
// Initial boot.
async function boot() {
try {
const st = await invoke("status");
renderStatus(st);
if (st.engine_present) setState(paused ? "paused" : "protected");
else setState("paused");
} catch {
setEngineDot("bad", "engine offline");
setState("paused");
$("hero-sub").textContent =
"Can't reach houndd. Is the daemon running? (try `cargo run -p houndd`)";
}
}
boot();

BIN
gui/dist/favicon-32.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
gui/dist/hound-32.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

285
gui/dist/index.html vendored Normal file
View file

@ -0,0 +1,285 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hound Antivirus</title>
<link rel="stylesheet" href="styles.css" />
<link rel="icon" type="image/png" href="favicon-32.png" />
</head>
<body>
<div id="app">
<!-- ── Header ─────────────────────────────────────────────── -->
<header class="header">
<div class="brand">
<img src="hound-32.png" alt="" class="brand-mark" />
<div class="brand-text">
<h1>Hound</h1>
<span class="tagline">Antivirus for Linux</span>
</div>
</div>
<div class="header-status">
<span class="engine-dot" id="engine-dot" title="Engine status"></span>
<span class="engine-label" id="engine-label">connecting…</span>
</div>
</header>
<!-- ── Tabs ───────────────────────────────────────────────── -->
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="protection" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Protection
</button>
<button class="tab" data-tab="quarantine" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8v13H3V8"/><path d="M1 3h22v5H1z"/><path d="M10 12h4"/></svg>
Quarantine
<span class="tab-badge hidden" id="badge-quarantine"></span>
</button>
<button class="tab" data-tab="realtime" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12h4l3-9 4 18 3-9h6"/></svg>
Realtime
</button>
<button class="tab" data-tab="rootkit" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
Rootkit
</button>
<button class="tab" data-tab="alerts" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
Alerts
<span class="tab-badge hidden" id="badge-alerts"></span>
</button>
<button class="tab" data-tab="settings" role="tab">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</button>
</nav>
<!-- ══ Protection ═══════════════════════════════════════════ -->
<section class="tab-panel active" id="panel-protection">
<section class="hero" id="hero">
<div class="shield" id="shield">
<img id="shield-icon" src="state-protected-48.png" alt="" width="48" height="48" />
</div>
<div class="hero-copy">
<h2 id="hero-title">Protected</h2>
<p id="hero-sub">Your system looks healthy.</p>
<div class="hero-meta">
<span class="pill" id="pill-db">signatures: —</span>
<span class="pill" id="pill-os"></span>
<span class="pill" id="pill-engine">engine: —</span>
</div>
</div>
</section>
<section class="actions">
<button class="btn primary" id="btn-scan-home">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
Scan Home
</button>
<button class="btn primary" id="btn-scan-custom">Scan Folder…</button>
<button class="btn" id="btn-update">Update Signatures</button>
<button class="btn" id="btn-pause">Pause Protection</button>
</section>
<section class="panel hidden" id="scan-panel">
<div class="panel-head">
<h3 id="scan-title">Scanning…</h3>
<span class="scan-target" id="scan-target"></span>
</div>
<div class="progress">
<div class="progress-bar" id="progress-bar"></div>
</div>
<p class="muted" id="scan-note">ClamAV is working the queue. This can take a while on large folders.</p>
</section>
<section class="panel" id="results-panel">
<div class="panel-head">
<h3>Last Scan Results</h3>
<span class="muted" id="results-meta"></span>
</div>
<div id="results-body" class="results">
<p class="muted empty">No scans yet. Hit <strong>Scan Home</strong> to start.</p>
</div>
</section>
<section class="panel hidden" id="update-panel">
<div class="panel-head">
<h3>Signature Update</h3>
</div>
<pre class="log" id="update-log"></pre>
</section>
</section>
<!-- ══ Quarantine ═══════════════════════════════════════════ -->
<section class="tab-panel" id="panel-quarantine">
<section class="panel">
<div class="panel-head">
<h3>Quarantine Vault</h3>
<div class="row-actions">
<span class="muted" id="qt-count"></span>
<button class="btn small" id="btn-qt-refresh">Refresh</button>
</div>
</div>
<div id="qt-body" class="qt-list">
<p class="muted empty">Loading…</p>
</div>
</section>
<section class="panel">
<div class="panel-head"><h3>Add Manually</h3></div>
<div class="form-row">
<input type="text" id="qt-add-path" class="input" placeholder="/path/to/suspicious/file" />
<input type="text" id="qt-add-virus" class="input" placeholder="label (e.g. manual-suspicious)" value="manual" />
<button class="btn" id="btn-qt-add">Quarantine</button>
</div>
<p class="muted" style="margin-top:8px">The file is moved into the vault; its original location is left empty.</p>
</section>
</section>
<!-- ══ Realtime ═════════════════════════════════════════════ -->
<section class="tab-panel" id="panel-realtime">
<section class="panel">
<div class="panel-head">
<h3>Realtime Monitor</h3>
<label class="switch-row">
<span id="rt-enabled-label" class="muted">loading…</span>
<input type="checkbox" id="rt-toggle" />
<span class="switch"></span>
</label>
</div>
<div class="stat-grid" id="rt-stats">
<div class="stat"><span class="stat-val" id="rt-seen"></span><span class="stat-key">file events seen</span></div>
<div class="stat"><span class="stat-val" id="rt-quar"></span><span class="stat-key">auto-quarantined</span></div>
<div class="stat"><span class="stat-val" id="rt-uptime"></span><span class="stat-key">monitor uptime</span></div>
<div class="stat"><span class="stat-val" id="rt-ransom"></span><span class="stat-key">ransomware guard</span></div>
</div>
<div class="kv"><span class="kv-k">watching</span><span class="kv-v" id="rt-watch"></span></div>
<div class="kv"><span class="kv-k">last file event</span><span class="kv-v" id="rt-last"></span></div>
<div class="kv"><span class="kv-k">on detection</span><span class="kv-v" id="rt-action"></span></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Watch Folders</h3></div>
<div id="rt-watch-list" class="watch-list"></div>
<p class="muted" style="margin-top:8px">Edit in Settings → Realtime. Restart required for the monitor to pick up new folders.</p>
</section>
</section>
<!-- ══ Rootkit ══════════════════════════════════════════════ -->
<section class="tab-panel" id="panel-rootkit">
<section class="panel">
<div class="panel-head">
<h3>Rootkit Hunter</h3>
<button class="btn primary" id="btn-rootkit">Run Scan</button>
</div>
<div id="rootkit-banner" class="verdict hidden"></div>
<div id="rootkit-body" class="finding-list">
<p class="muted empty">Runs userspace heuristics: deleted-but-open executables, world-writable binaries, unexpected setuid, hidden /proc entries.</p>
</div>
</section>
</section>
<!-- ══ Alerts ═══════════════════════════════════════════════ -->
<section class="tab-panel" id="panel-alerts">
<section class="panel">
<div class="panel-head">
<h3>Event Log</h3>
<div class="row-actions">
<span class="muted" id="alerts-count"></span>
<button class="btn small" id="btn-alerts-refresh">Refresh</button>
<button class="btn small danger" id="btn-alerts-clear">Clear Log</button>
</div>
</div>
<div id="alerts-body" class="event-list">
<p class="muted empty">Loading…</p>
</div>
</section>
</section>
<!-- ══ Settings ═════════════════════════════════════════════ -->
<section class="tab-panel" id="panel-settings">
<section class="panel">
<div class="panel-head"><h3>Protection</h3></div>
<label class="switch-row setting">
<span><strong>Pause all protection</strong><small>Suspends realtime and grays the tray.</small></span>
<input type="checkbox" id="set-paused" /><span class="switch"></span>
</label>
<label class="switch-row setting">
<span><strong>Auto-update signatures</strong><small>Daemon runs freshclam on a schedule.</small></span>
<input type="checkbox" id="set-autoupdate" /><span class="switch"></span>
</label>
<label class="switch-row setting">
<span><strong>Desktop notifications</strong><small>Pop an alert for critical events.</small></span>
<input type="checkbox" id="set-notify" /><span class="switch"></span>
</label>
</section>
<section class="panel">
<div class="panel-head"><h3>Realtime</h3></div>
<label class="switch-row setting">
<span><strong>Realtime monitor</strong><small>Watch folders and quarantine on the fly.</small></span>
<input type="checkbox" id="set-realtime" /><span class="switch"></span>
</label>
<div class="field">
<label for="set-watch">Watch folders (one per line)</label>
<textarea id="set-watch" class="input mono" rows="4" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="set-ondetect">Action on detection</label>
<select id="set-ondetect" class="input">
<option value="quarantine">Quarantine automatically</option>
<option value="alert">Alert only (leave file in place)</option>
</select>
</div>
</section>
<section class="panel">
<div class="panel-head"><h3>Scanning</h3></div>
<div class="field">
<label for="set-maxsize">Max file size (MB)</label>
<input type="number" id="set-maxsize" class="input" min="1" />
</div>
<div class="field">
<label for="set-excludes">Excluded paths (one per line, exact or prefix)</label>
<textarea id="set-excludes" class="input mono" rows="4" spellcheck="false"></textarea>
</div>
<label class="switch-row setting">
<span><strong>Recursive by default</strong></span>
<input type="checkbox" id="set-recursive" /><span class="switch"></span>
</label>
</section>
<section class="panel">
<div class="panel-head"><h3>Ransomware Guard</h3></div>
<label class="switch-row setting">
<span><strong>Enable guard</strong><small>Write-burst heuristic trips an alarm under attack.</small></span>
<input type="checkbox" id="set-ransom" /><span class="switch"></span>
</label>
<div class="field">
<label for="set-ransom-thresh">Threshold (file-writes / minute)</label>
<input type="number" id="set-ransom-thresh" class="input" min="10" />
</div>
</section>
<section class="panel">
<div class="panel-head"><h3>Rootkit</h3></div>
<label class="switch-row setting">
<span><strong>Include in scans</strong><small>Run rootkit heuristics as part of the health check.</small></span>
<input type="checkbox" id="set-rootkit" /><span class="switch"></span>
</label>
</section>
<div class="actions">
<button class="btn primary" id="btn-settings-save">Save Settings</button>
<span class="muted" id="settings-msg"></span>
</div>
</section>
<footer class="footer">
<span id="foot-ver">houndd —</span>
<span class="muted">engine: ClamAV via Unix socket</span>
</footer>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

BIN
gui/dist/state-paused-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
gui/dist/state-protected-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
gui/dist/state-scanning-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
gui/dist/state-threat-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

446
gui/dist/styles.css vendored Normal file
View file

@ -0,0 +1,446 @@
:root {
/* surfaces */
--bg: #0B0E14;
--bg-raised: #12161F;
--bg-panel: #161B26;
--bg-hover: #1D2432;
--border: #232B3A;
--border-hi: #32405A;
/* text */
--fg: #E8ECF4;
--fg-dim: #8B96AB;
--fg-faint: #5A6478;
/* brand + state (dog head, 4-state ladder) */
--brand: #9896E0;
--ok: #22C55E;
--warn: #F59E0B;
--bad: #EF4444;
--off: #6B7280;
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 8px 30px rgb(0 0 0 / 0.45);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: radial-gradient(1200px 700px at 20% -10%, #141B2B 0%, var(--bg) 55%);
color: var(--fg);
font: 15px/1.5 "Inter", "Cantarell", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
#app {
max-width: 920px;
margin: 0 auto;
padding: 26px 26px 18px;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100vh;
}
/* ── Header ─────────────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 34px; height: 34px; filter: drop-shadow(0 2px 8px rgb(152 150 224 / .35)); }
.brand-text h1 { font-size: 20px; font-weight: 700; letter-spacing: .2px; }
.tagline { font-size: 12px; color: var(--fg-dim); }
.header-status { display: flex; align-items: center; gap: 8px; }
.engine-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--off);
box-shadow: 0 0 8px rgb(107 114 128 / .6);
transition: background .3s, box-shadow .3s;
}
.engine-dot.ok { background: var(--ok); box-shadow: 0 0 10px rgb(34 197 94 / .7); }
.engine-dot.warn { background: var(--warn); box-shadow: 0 0 10px rgb(245 158 11 / .7); }
.engine-dot.bad { background: var(--bad); box-shadow: 0 0 10px rgb(239 68 68 / .7); }
.engine-label { font-size: 12.5px; color: var(--fg-dim); }
/* ── Tabs ───────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 4px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px;
overflow-x: auto;
}
.tab {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--fg-dim);
font: 600 13px/1 inherit;
border-radius: 10px;
padding: 9px 14px;
cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
white-space: nowrap;
transition: background .15s, color .15s, border-color .15s;
position: relative;
}
.tab:hover { background: var(--bg-hover); color: var(--fg); }
.tab.active {
background: linear-gradient(180deg, #232B40, #1B2233);
border-color: var(--border-hi);
color: var(--fg);
box-shadow: inset 0 1px 0 rgb(255 255 255 / .04);
}
.tab svg { opacity: .8; }
.tab-badge {
min-width: 18px;
font-size: 10.5px;
font-weight: 700;
text-align: center;
padding: 1px 5px;
border-radius: 999px;
background: var(--bad);
color: #fff;
line-height: 1.5;
}
.tab-badge.quiet { background: var(--warn); }
.tab-panel { display: none; flex-direction: column; gap: 18px; }
.tab-panel.active { display: flex; }
/* ── Hero ───────────────────────────────────────────────────────── */
.hero {
display: flex;
align-items: center;
gap: 22px;
background: linear-gradient(180deg, var(--bg-panel), var(--bg-raised));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 26px;
box-shadow: var(--shadow);
}
.shield {
flex: 0 0 auto;
width: 84px; height: 84px;
display: grid; place-items: center;
border-radius: 50%;
background: rgb(152 150 224 / .07);
border: 1px solid rgb(152 150 224 / .18);
transition: border-color .3s, background .3s;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.06); opacity: .85; }
}
.hero-copy h2 { font-size: 22px; font-weight: 700; }
.hero-copy p { color: var(--fg-dim); margin: 4px 0 12px; }
.hero[data-state="protected"] .shield { border-color: rgb(34 197 94 / .35); background: rgb(34 197 94 / .08); }
.hero[data-state="scanning"] .shield { border-color: rgb(245 158 11 / .4); background: rgb(245 158 11 / .09); }
.hero[data-state="threat"] .shield { border-color: rgb(239 68 68 / .45); background: rgb(239 68 68 / .1); }
.hero[data-state="paused"] .shield { border-color: rgb(107 114 128 / .4); background: rgb(107 114 128 / .09); }
.hero[data-state="scanning"] #shield-icon { animation: pulse 1.6s ease-in-out infinite; }
.pill {
display: inline-block;
font-size: 12px;
color: var(--fg-dim);
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 11px;
margin-right: 8px;
white-space: nowrap;
}
.pill.stale { color: var(--warn); border-color: rgb(245 158 11 / .4); }
/* ── Actions ────────────────────────────────────────────────────── */
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.btn {
appearance: none;
border: 1px solid var(--border-hi);
background: var(--bg-hover);
color: var(--fg);
font: 600 13.5px/1 inherit;
border-radius: var(--radius-sm);
padding: 11px 16px;
cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
transition: background .15s, border-color .15s, transform .05s;
}
.btn:hover { background: #232C3E; border-color: #41507a; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: default; }
.btn.primary {
background: linear-gradient(180deg, #5A58D6, #4543C4);
border-color: #6a68e6;
}
.btn.primary:hover { background: linear-gradient(180deg, #6765e0, #4f4dd4); }
.btn.small { padding: 7px 12px; font-size: 12.5px; }
.btn.danger { border-color: rgb(239 68 68 / .5); }
.btn.danger:hover { background: rgb(239 68 68 / .15); border-color: var(--bad); }
/* ── Panels ─────────────────────────────────────────────────────── */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
gap: 12px;
}
.panel-head h3 { font-size: 14px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; color: var(--fg-dim); }
.muted { color: var(--fg-dim); font-size: 13px; }
.empty { padding: 6px 0 2px; }
.hidden { display: none !important; }
.row-actions { display: flex; align-items: center; gap: 10px; }
.scan-target { font-family: ui-monospace, monospace; font-size: 12.5px; color: var(--fg-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* scan progress */
.progress {
height: 8px;
border-radius: 999px;
background: var(--bg-hover);
overflow: hidden;
margin-bottom: 10px;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: inherit;
transition: width .4s ease;
}
.progress.indeterminate .progress-bar {
width: 35%;
animation: slide 1.3s ease-in-out infinite alternate;
}
@keyframes slide { from { margin-left: 0; } to { margin-left: 65%; } }
/* results */
.results { display: flex; flex-direction: column; gap: 6px; }
.result-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
}
.result-row .sig { font-weight: 700; font-size: 13.5px; }
.result-row .path { color: var(--fg-dim); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
.result-row.clean { border-left: 3px solid var(--ok); }
.result-row.clean .sig { color: var(--ok); }
.result-row.infected { border-left: 3px solid var(--bad); }
.result-row.infected .sig { color: var(--bad); }
/* log */
.log {
background: #0A0D13;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 14px;
font: 12px/1.6 ui-monospace, "JetBrains Mono", monospace;
color: var(--fg-dim);
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
}
.log.ok { color: #7fd79a; }
.log.fail { color: #f2a2a2; }
/* ── Inputs & switches ──────────────────────────────────────────── */
.input {
appearance: none;
background: var(--bg-raised);
border: 1px solid var(--border-hi);
border-radius: var(--radius-sm);
color: var(--fg);
font: 13.5px/1.4 inherit;
padding: 9px 12px;
width: 100%;
}
.input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgb(152 150 224 / .15); }
.input.mono { font-family: ui-monospace, "JetBrains Mono", monospace; font-size: 12.5px; }
textarea.input { resize: vertical; }
select.input { cursor: pointer; }
.form-row { display: grid; grid-template-columns: 1fr 200px auto; gap: 10px; }
.field { margin: 14px 0; }
.field > label { display: block; font-size: 13px; font-weight: 600; color: var(--fg-dim); margin-bottom: 6px; }
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
padding: 4px 0;
}
.switch-row.setting { padding: 10px 0; border-bottom: 1px solid var(--border); }
.switch-row.setting:last-of-type { border-bottom: none; }
.switch-row strong { display: block; font-size: 14px; font-weight: 600; }
.switch-row small { display: block; color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
.switch-row input[type="checkbox"] { position: absolute; opacity: 0; pointer-events: none; }
.switch {
flex: 0 0 auto;
width: 40px; height: 22px;
border-radius: 999px;
background: var(--bg-hover);
border: 1px solid var(--border-hi);
position: relative;
transition: background .2s, border-color .2s;
}
.switch::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--fg-dim);
transition: transform .2s, background .2s;
}
.switch-row input:checked + .switch { background: rgb(90 88 214 / .55); border-color: #6a68e6; }
.switch-row input:checked + .switch::after { transform: translateX(18px); background: #fff; }
/* ── Quarantine ─────────────────────────────────────────────────── */
.qt-list { display: flex; flex-direction: column; gap: 6px; }
.qt-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
background: var(--bg-hover);
border: 1px solid var(--border);
border-left: 3px solid var(--bad);
border-radius: var(--radius-sm);
padding: 10px 14px;
}
.qt-row.restored { border-left-color: var(--off); opacity: .75; }
.qt-row .qt-virus { font-weight: 700; font-size: 13px; color: var(--bad); white-space: nowrap; }
.qt-row.restored .qt-virus { color: var(--fg-dim); }
.qt-row .qt-path { font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qt-row .qt-meta { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; }
.qt-row .qt-actions { display: flex; gap: 6px; }
/* ── Realtime ───────────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin: 12px 0 6px;
}
.stat {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-val { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-val.alarm { color: var(--bad); }
.stat-val.watching { color: var(--warn); }
.stat-val.calm { color: var(--ok); }
.stat-key { font-size: 11.5px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .4px; }
.kv {
display: flex;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--border);
font-size: 13px;
}
.kv-k { flex: 0 0 150px; color: var(--fg-faint); }
.kv-v { color: var(--fg-dim); font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
.watch-list { display: flex; flex-wrap: wrap; gap: 6px; }
.watch-chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--fg-dim);
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 11px;
}
/* ── Rootkit ────────────────────────────────────────────────────── */
.verdict {
border-radius: var(--radius-sm);
padding: 12px 16px;
margin-bottom: 12px;
font-weight: 600;
font-size: 14px;
border: 1px solid;
}
.verdict.clean { background: rgb(34 197 94 / .08); border-color: rgb(34 197 94 / .35); color: #7fd79a; }
.verdict.dirty { background: rgb(239 68 68 / .08); border-color: rgb(239 68 68 / .4); color: #f2a2a2; }
.finding-list { display: flex; flex-direction: column; gap: 6px; }
.finding-row {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 12px;
align-items: center;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
}
.finding-row .sev {
font-size: 10.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: .5px;
padding: 3px 9px; border-radius: 999px;
}
.sev.critical { background: rgb(239 68 68 / .15); color: var(--bad); }
.sev.warn { background: rgb(245 158 11 / .15); color: var(--warn); }
.sev.info { background: rgb(107 114 128 / .2); color: var(--fg-dim); }
.finding-row .check { font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg-dim); }
.finding-row .detail { font-size: 13px; color: var(--fg); word-break: break-all; }
/* ── Alerts ─────────────────────────────────────────────────────── */
.event-list { display: flex; flex-direction: column; gap: 4px; max-height: 520px; overflow-y: auto; }
.event-row {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 12px;
align-items: center;
border-radius: var(--radius-sm);
padding: 8px 14px;
background: var(--bg-hover);
border: 1px solid var(--border);
}
.event-row .ev-sev { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; width: 64px; }
.ev-sev.critical { color: var(--bad); }
.ev-sev.warn { color: var(--warn); }
.ev-sev.info { color: var(--fg-faint); }
.event-row .ev-kind { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--fg-dim); width: 96px; }
.event-row .ev-msg { font-size: 13px; color: var(--fg); word-break: break-word; }
.event-row .ev-ts { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
/* footer */
.footer {
margin-top: auto;
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--fg-faint);
padding-top: 4px;
}

280
gui/package-lock.json generated Normal file
View file

@ -0,0 +1,280 @@
{
"name": "hound-gui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hound-gui",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-notification": "^2.3.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
}
}
}

View file

@ -12,6 +12,8 @@
"@tauri-apps/cli": "^2.5.0"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0"
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-notification": "^2.3.3"
}
}

117
gui/src-tauri/Cargo.lock generated
View file

@ -1486,6 +1486,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
]
@ -2050,6 +2051,20 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@ -2164,6 +2179,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@ -2649,6 +2678,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@ -2753,6 +2791,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@ -3624,6 +3691,25 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -3746,6 +3832,17 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -5098,6 +5195,26 @@ dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View file

@ -14,6 +14,7 @@ repository = "https://git.joelovestech.com/Hound/Antivirus"
hound-api = { path = "../../crates/hound-api" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,13 @@
[
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"dialog:default",
"notification:default",
"opener:default"
]
}
]

View file

@ -4,25 +4,40 @@
//! The system-tray sentinel swaps between the four state icons:
//!
//! protected (green) / scanning (amber) / threat (red) / paused (gray)
//!
//! A background watcher polls the daemon once a second: it keeps the tray in
//! step with daemon-side state (realtime threat → red, scan in flight →
//! amber) and fires a desktop notification for every *new* critical event
//! the daemon logs (quarantine, ransomware alarm, rootkit finding).
use hound_api::{Client, ScanResult, Status, UpdateResult};
use hound_api::{
Client, Event, RealtimeStatus, RootkitScan, ScanResult, Settings, Status, UpdateResult,
QuarantineEntry,
};
use serde_json::json;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::image::Image;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager, State};
use tauri_plugin_notification::NotificationExt as _;
type R<T> = anyhow::Result<T>;
const TRAY_ID: &str = "hound-tray";
const POLL_SECS: u64 = 1;
/// Shared between the `scan` command (sets it) and the tray watcher (reads
/// it) so the sentinel shows "scanning" while an on-demand scan is running.
static SCANNING: AtomicBool = AtomicBool::new(false);
/// States the tray can render. Anything unknown falls back to `protected`.
const STATES: [&str; 4] = ["protected", "scanning", "threat", "paused"];
/// The four preloaded state icons, managed so tray swaps never hit disk.
#[derive(Default)]
#[derive(Clone, Default)]
struct TrayIcons(HashMap<String, Image<'static>>);
fn client() -> Client {
@ -43,10 +58,12 @@ async fn status() -> Result<Status, String> {
#[tauri::command]
async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
SCANNING.store(true, Ordering::Relaxed);
let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
SCANNING.store(false, Ordering::Relaxed);
r.map_err(|e| e.to_string())
}
#[tauri::command]
@ -58,30 +75,135 @@ async fn update() -> Result<UpdateResult, String> {
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn settings() -> Result<Settings, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.settings())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_settings(s: Settings) -> Result<Settings, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.set_settings(&s))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn events(limit: u32) -> Result<Vec<Event>, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.events(limit))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn clear_events() -> Result<u64, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.clear_events())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_list() -> Result<Vec<QuarantineEntry>, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_list())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_add(path: String, virus: String) -> Result<QuarantineEntry, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_add(&path, &virus))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_remove(id: String) -> Result<u64, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn rootkit_scan() -> Result<RootkitScan, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.rootkit_scan())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn realtime_status() -> Result<RealtimeStatus, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.realtime_status())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
/// Swap the tray icon + tooltip. The window calls this to keep the sentinel
/// in step with what the user is doing (e.g. it starts a scan).
/// in step with what the user is doing (e.g. it starts a scan). The backend
/// watcher also calls the same routine as daemon state changes underneath.
fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
let state = if STATES.contains(&state) {
state
} else {
"protected"
};
let img = match icons.0.get(state) {
Some(i) => i.clone(),
None => icons
.0
.get("protected")
.cloned()
.expect("protected icon always loaded"),
};
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_icon(Some(img));
let _ = tray.set_tooltip(Some(tooltip_for(state)));
}
}
#[tauri::command]
fn set_state(
app: tauri::AppHandle,
icons: State<'_, TrayIcons>,
state: String,
) -> Result<(), String> {
let state = if STATES.contains(&state.as_str()) {
state
} else {
"protected".into()
};
let img = icons.0.get(&state).cloned().unwrap_or_else(|| {
icons
.0
.get("protected")
.cloned()
.expect("protected icon always loaded")
});
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_icon(Some(img));
let _ = tray.set_tooltip(Some(tooltip_for(&state)));
}
apply_state(&app, &icons, &state);
Ok(())
}
@ -94,6 +216,64 @@ fn tooltip_for(state: &str) -> String {
}
}
// ── Tray watcher: state sync + critical-event notifications ─────────────────
/// Poll the daemon; reflect daemon-side state in the tray and notify on new
/// critical events the frontend hasn't already surfaced. Runs as a plain
/// OS thread: one blocking client round-trip per tick is exactly what this
/// wants, and it keeps the command side free of scheduler gymnastics.
fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
std::thread::spawn(move || {
let mut last_seen_id: u64 = 0;
loop {
std::thread::sleep(std::time::Duration::from_secs(POLL_SECS));
let c = client();
if c.status().is_err() {
continue; // daemon restarting — skip the tick
}
let paused = c
.settings()
.map(|s| s.paused)
.unwrap_or(false);
// Derive tray state: a fresh critical event wins, then an
// in-flight scan, then paused, then protected.
let mut state = "protected";
if let Ok(evts) = c.events(20) {
if let Some(latest) = evts.first() {
if latest.severity == "critical" && latest.id > last_seen_id {
last_seen_id = latest.id;
state = "threat";
notify(&app, latest);
}
}
}
if state == "protected" {
if SCANNING.load(Ordering::Relaxed) {
state = "scanning";
} else if paused {
state = "paused";
}
}
apply_state(&app, &icons, state);
}
});
}
fn notify(app: &tauri::AppHandle, ev: &Event) {
let title = match ev.kind.as_str() {
"quarantine" | "threat" => "Hound — threat quarantined",
"ransomware" => "Hound — ransomware suspected",
"rootkit" => "Hound — rootkit activity",
_ => "Hound — alert",
};
let notification = app.notification();
let _ = notification
.builder()
.title(title)
.body(&ev.message)
.show();
}
// ── Icon resolution ─────────────────────────────────────────────────────────
fn icon_dir(app: &tauri::AppHandle) -> PathBuf {
@ -136,8 +316,25 @@ fn load_icon(path: &Path) -> R<Image<'static>> {
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![status, scan, update, set_state])
.invoke_handler(tauri::generate_handler![
status,
scan,
update,
settings,
set_settings,
events,
clear_events,
quarantine_list,
quarantine_add,
quarantine_restore,
quarantine_remove,
rootkit_scan,
realtime_status,
realtime_set_enabled,
set_state
])
.setup(|app| {
let handle = app.handle().clone();
let icons = TrayIcons(load_state_icons(&handle)?);
@ -146,6 +343,7 @@ pub fn run() {
.get("protected")
.expect("protected icon loaded")
.clone();
let watcher_icons = icons.clone();
app.manage(icons);
let open = MenuItem::with_id(&handle, "open", "Open Hound", true, None::<&str>)?;
@ -211,6 +409,8 @@ pub fn run() {
})
.build(&handle)?;
start_watcher(handle.clone(), watcher_icons);
Ok(())
})
.run(tauri::generate_context!())