diff --git a/.gitignore b/.gitignore index 2ebd7a4..34a0215 100644 --- a/.gitignore +++ b/.gitignore @@ -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) ── diff --git a/Cargo.lock b/Cargo.lock index 435d1e8..a18aac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d72dbe8..8703287 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 7ac9976..82faa92 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -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, + /// 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, + + // Real-time interception + pub realtime_enabled: bool, + /// Directories the realtime monitor watches (recursively). + pub realtime_watch: Vec, + /// 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, + /// 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, + /// 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, + /// 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, - ) -> anyhow::Result { + pub fn call(&self, id: u64, method: &str, params: Option) -> anyhow::Result { 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 { + let v = self.call(4, "settings.get", None)?; + Ok(serde_json::from_value(v)?) + } + + pub fn set_settings(&self, s: &Settings) -> anyhow::Result { + 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> { + 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 { + let v = self.call(7, "events.clear", None)?; + Ok(serde_json::from_value(v)?) + } + + // ── quarantine ── + pub fn quarantine_list(&self) -> anyhow::Result> { + 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 { + 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 { + 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 { + 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 { + let v = self.call(12, "rootkit.scan", None)?; + Ok(serde_json::from_value(v)?) + } + + // ── realtime ── + pub fn realtime_status(&self) -> anyhow::Result { + let v = self.call(13, "realtime.status", None)?; + Ok(serde_json::from_value(v)?) + } + + pub fn realtime_set_enabled(&self, enabled: bool) -> anyhow::Result { + let v = self.call( + 14, + "realtime.set_enabled", + Some(serde_json::json!({ "enabled": enabled })), + )?; + Ok(serde_json::from_value(v)?) + } } diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 9fcb495..0035eb6 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -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, + }, + /// 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, + }, +} + +#[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 { 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 { 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!( diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index 04ecfdf..3026a66 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -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 diff --git a/crates/houndd/src/engine.rs b/crates/houndd/src/engine.rs index cbcdef6..081d749 100644 --- a/crates/houndd/src/engine.rs +++ b/crates/houndd/src/engine.rs @@ -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 { } // 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) { + ( + 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 { + 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()); } } diff --git a/crates/houndd/src/events.rs b/crates/houndd/src/events.rs new file mode 100644 index 0000000..b2be9ce --- /dev/null +++ b/crates/houndd/src/events.rs @@ -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>>, + next_id: Arc, +} + +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 { + let guard = self.inner.lock().unwrap(); + let mut v: Vec = 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())); + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index f05d0fa..58d7bfd 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -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, bytes: &str) -> Result<()> { Ok(()) } -fn dispatch(req: &hound_api::Request) -> Result { +fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { 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 { .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 { + 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 { +fn update(st: &DaemonState) -> Result { 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 { - 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); + } } diff --git a/crates/houndd/src/quarantine.rs b/crates/houndd/src/quarantine.rs new file mode 100644 index 0000000..297334d --- /dev/null +++ b/crates/houndd/src/quarantine.rs @@ -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/ +//! ← the file's bytes +//! .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>>, +} + +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 { + let dir = store_dir(); + let mut entries: Vec = 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::(&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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/houndd/src/realtime.rs b/crates/houndd/src/realtime.rs new file mode 100644 index 0000000..bb2f00b --- /dev/null +++ b/crates/houndd/src/realtime.rs @@ -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, + 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>, + started_at: Arc>>, + running: Arc, + active: Arc, + handle: Arc>>>, + watch_dirs: Arc>>, + 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 { + 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 = 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, + counters: &Arc>, + running: &Arc, + active: &Arc, + settings: &SettingsStore, + quarantine: &Quarantine, + events: &EventLog, + watch_dirs: &Arc>>, +) { + 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 = 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 = VecDeque::new(); + let engine = engine(); + + while running.load(Ordering::Relaxed) { + let evts = match inotify.read_events(&mut buf) { + Ok(iter) => iter.collect::>(), + // 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, +) { + 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>( + ev: &inotify::Event, + wd_map: &HashMap, +) -> 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 { + 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 { + 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); + } + } +} diff --git a/crates/houndd/src/rootkit.rs b/crates/houndd/src/rootkit.rs new file mode 100644 index 0000000..ae287e9 --- /dev/null +++ b/crates/houndd/src/rootkit.rs @@ -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/` 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 = 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 { + 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/ unreadable. + if let Ok(md) = e.metadata() { + if !md.is_dir() { + continue; + } + } + // Read the first byte of /proc//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 { + 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 { + 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 { + 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 = 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); + } +} diff --git a/crates/houndd/src/settings.rs b/crates/houndd/src/settings.rs new file mode 100644 index 0000000..c26cd93 --- /dev/null +++ b/crates/houndd/src/settings.rs @@ -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>, +} + +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::(&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); + } +} diff --git a/crates/houndd/src/test_util.rs b/crates/houndd/src/test_util.rs new file mode 100644 index 0000000..0045596 --- /dev/null +++ b/crates/houndd/src/test_util.rs @@ -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::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) +} diff --git a/gui/dist/app.js b/gui/dist/app.js new file mode 100644 index 0000000..72694a2 --- /dev/null +++ b/gui/dist/app.js @@ -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 = + `
+ ✔ All clear + ${r.scanned.toLocaleString()} files scanned, nothing flagged + +
`; + } else { + for (const f of r.found.slice(0, 50)) { + const div = document.createElement("div"); + div.className = "result-row infected"; + div.innerHTML = + `✘ ${escapeHtml(f.virus)} + ${escapeHtml(f.path)} + `; + 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 = '

Nothing in quarantine. Your clean system thanks you.

'; + return; + } + for (const e of list) { + const row = document.createElement("div"); + row.className = "qt-row" + (e.restored ? " restored" : ""); + row.innerHTML = + `${escapeHtml(e.virus)} + + ${escapeHtml(e.original_path)}
+ ${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""} +
+ `; + 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 = `

Failed to load: ${escapeHtml(String(e))}

`; + } +} + +// ── 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 = '

Checking setuid bits, deleted executables, world-writable binaries…

'; + 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 = '

No findings at all — squeaky clean.

'; + 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 = + `${escapeHtml(f.severity)} + ${escapeHtml(f.check)} + ${escapeHtml(f.detail)}`; + body.appendChild(row); + } + } catch (e) { + $("rootkit-body").innerHTML = `

Scan failed: ${escapeHtml(String(e))}

`; + } 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 = '

No events yet — the dog hasn’t barked.

'; + return; + } + for (const e of list) { + const row = document.createElement("div"); + row.className = "event-row"; + row.innerHTML = + `${escapeHtml(e.severity)} + ${escapeHtml(e.kind)} + ${escapeHtml(e.message)} + ${fmtTime(e.ts)}`; + body.appendChild(row); + } + } catch (e) { + $("alerts-body").innerHTML = `

Failed to load: ${escapeHtml(String(e))}

`; + } +} + +// ── 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) => + ({ "&": "&", "<": "<", ">": ">", '"': """ }[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(); diff --git a/gui/dist/favicon-32.png b/gui/dist/favicon-32.png new file mode 100644 index 0000000..4b182b9 Binary files /dev/null and b/gui/dist/favicon-32.png differ diff --git a/gui/dist/hound-32.png b/gui/dist/hound-32.png new file mode 100644 index 0000000..4b182b9 Binary files /dev/null and b/gui/dist/hound-32.png differ diff --git a/gui/dist/index.html b/gui/dist/index.html new file mode 100644 index 0000000..f0cc9fc --- /dev/null +++ b/gui/dist/index.html @@ -0,0 +1,285 @@ + + + + + + Hound Antivirus + + + + +
+ + +
+
+ +
+

Hound

+ Antivirus for Linux +
+
+
+ + connecting… +
+
+ + + + + +
+
+
+ +
+
+

Protected

+

Your system looks healthy.

+
+ signatures: — + + engine: — +
+
+
+ +
+ + + + +
+ + + +
+
+

Last Scan Results

+ +
+
+

No scans yet. Hit Scan Home to start.

+
+
+ + +
+ + +
+
+
+

Quarantine Vault

+
+ + +
+
+
+

Loading…

+
+
+
+

Add Manually

+
+ + + +
+

The file is moved into the vault; its original location is left empty.

+
+
+ + +
+
+
+

Realtime Monitor

+ +
+
+
file events seen
+
auto-quarantined
+
monitor uptime
+
ransomware guard
+
+
watching
+
last file event
+
on detection
+
+
+

Watch Folders

+
+

Edit in Settings → Realtime. Restart required for the monitor to pick up new folders.

+
+
+ + +
+
+
+

Rootkit Hunter

+ +
+ +
+

Runs userspace heuristics: deleted-but-open executables, world-writable binaries, unexpected setuid, hidden /proc entries.

+
+
+
+ + +
+
+
+

Event Log

+
+ + + +
+
+
+

Loading…

+
+
+
+ + +
+
+

Protection

+ + + +
+ +
+

Realtime

+ +
+ + +
+
+ + +
+
+ +
+

Scanning

+
+ + +
+
+ + +
+ +
+ +
+

Ransomware Guard

+ +
+ + +
+
+ +
+

Rootkit

+ +
+ +
+ + +
+
+ +
+ houndd — + engine: ClamAV via Unix socket +
+ +
+ + + diff --git a/gui/dist/state-paused-48.png b/gui/dist/state-paused-48.png new file mode 100644 index 0000000..0754d8d Binary files /dev/null and b/gui/dist/state-paused-48.png differ diff --git a/gui/dist/state-protected-48.png b/gui/dist/state-protected-48.png new file mode 100644 index 0000000..446a6ff Binary files /dev/null and b/gui/dist/state-protected-48.png differ diff --git a/gui/dist/state-scanning-48.png b/gui/dist/state-scanning-48.png new file mode 100644 index 0000000..ec593d9 Binary files /dev/null and b/gui/dist/state-scanning-48.png differ diff --git a/gui/dist/state-threat-48.png b/gui/dist/state-threat-48.png new file mode 100644 index 0000000..3c7e382 Binary files /dev/null and b/gui/dist/state-threat-48.png differ diff --git a/gui/dist/styles.css b/gui/dist/styles.css new file mode 100644 index 0000000..8cdd153 --- /dev/null +++ b/gui/dist/styles.css @@ -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; +} diff --git a/gui/package-lock.json b/gui/package-lock.json new file mode 100644 index 0000000..0154fcd --- /dev/null +++ b/gui/package-lock.json @@ -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" + } + } + } +} diff --git a/gui/package.json b/gui/package.json index 597cbea..23b4244 100644 --- a/gui/package.json +++ b/gui/package.json @@ -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" } } diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 1862225..4251558 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -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" diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 457bde3..8a40cd4 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -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" diff --git a/gui/src-tauri/capabilities/default.json b/gui/src-tauri/capabilities/default.json new file mode 100644 index 0000000..82dd0a7 --- /dev/null +++ b/gui/src-tauri/capabilities/default.json @@ -0,0 +1,13 @@ +[ + { + "identifier": "default", + "windows": ["main"], + "permissions": [ + "core:default", + "core:event:default", + "dialog:default", + "notification:default", + "opener:default" + ] + } +] diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index ef24044..18df6ae 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -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 = anyhow::Result; 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>); fn client() -> Client { @@ -43,10 +58,12 @@ async fn status() -> Result { #[tauri::command] async fn scan(path: String, recursive: bool) -> Result { 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 { .map_err(|e| e.to_string()) } +#[tauri::command] +async fn settings() -> Result { + 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 { + 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, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { 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!())