Full feature set: realtime monitor, quarantine vault, rootkit scan, settings, events
Daemon (houndd) - realtime.rs: inotify monitor over watched dirs (default ~/Downloads, ~/Documents, ~/Desktop), ClamAV scan on touch, on_detect action (quarantine/rename/remove), ransomware heuristic (writes/renames per minute above threshold -> 'watching'/'alarm' + critical event) - quarantine.rs: SHA-256-keyed vault under ~/.local/share/hound/quarantine, add/list/restore/remove with original-path metadata - rootkit.rs: setuid anomaly detection (allowlisted stock binaries), deleted-but-executing inodes, world-writable /usr /bin; 3 severity levels - settings.rs: persisted ~/.config/hound/settings.json, hot-reload on set - events.rs: ring buffer of severity-tagged events, query + clear API (hound-api): Settings, Event, QuarantineEntry, RootkitScan/ RootkitFinding, RealtimeStatus types + 10 client methods; Status gains engine field (engine-agnostic seam) CLI (hound): events, quarantine list|add|restore|remove, settings [show|paused|auto-update|notify|realtime on|off|watch|on-detect| max-size|exclude], rootkit, realtime [status|on|off] — color human output, --json everywhere GUI (Tauri 2): - 16 backend commands bridging every client method - tray watcher: 1s poll loop, 4-state icon ladder (green/amber/red/gray), desktop notification on fresh critical events - 6-tab frontend: Protection (hero + scan + update), Quarantine (vault manager + manual add), Realtime (stats + watch list + toggle), Rootkit (on-demand scan), Alerts (event log + clear), Settings (full editor) - capabilities/default.json for dialog/notification/event permissions Verified: 27/27 workspace tests, live E2E — EICAR dropped in ~/Downloads auto-quarantined by the running daemon (critical event logged, file removed from origin).
This commit is contained in:
parent
a7871b5e27
commit
6ef296caa1
29 changed files with 4417 additions and 99 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
# ── Node / GUI ───────────────────────────────────────
|
||||
gui/node_modules/
|
||||
gui/dist/
|
||||
# gui/dist is the hand-maintained static frontend (no build step) — tracked
|
||||
gui/dist/assets/
|
||||
gui/src-tauri/target/
|
||||
|
||||
# ── Env & secrets (repo-local credential file, chmod 600) ──
|
||||
|
|
|
|||
87
Cargo.lock
generated
87
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ pub fn default_socket_path() -> String {
|
|||
if let Ok(sock) = std::env::var("HOUNDD_SOCK") {
|
||||
return sock;
|
||||
}
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR")
|
||||
.unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
format!("{runtime}/houndd.sock")
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +67,7 @@ impl Response {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Status {
|
||||
pub daemon_version: String,
|
||||
/// Which engine the daemon is running (see `houndd::engine::ENGINE`).
|
||||
/// Which engine the daemon is running (see `houndd::engine::engine()`).
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
/// Whether the active engine is present/usable on this machine —
|
||||
|
|
@ -83,6 +82,12 @@ pub struct Status {
|
|||
/// stale → amber" instead of parsing `db_summary` prose.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub db: Option<DbFile>,
|
||||
/// Real-time interception status (watching, counters, uptime).
|
||||
#[serde(default)]
|
||||
pub realtime: RealtimeStatus,
|
||||
/// Number of files currently held in quarantine.
|
||||
#[serde(default)]
|
||||
pub quarantined: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -126,6 +131,157 @@ impl ScanResult {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The user-tunable knobs. Stored by the daemon, editable from the GUI
|
||||
/// or CLI. The daemon reloads the relevant parts live (realtime, pause).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
// Scanning
|
||||
/// Scan directories recursively by default.
|
||||
pub recursive_default: bool,
|
||||
/// Per-file size cap for clamscan (MB).
|
||||
pub max_file_size_mb: u64,
|
||||
/// Paths (exact or `/`-suffixed prefixes) skipped by scans.
|
||||
pub exclude_paths: Vec<String>,
|
||||
|
||||
// Real-time interception
|
||||
pub realtime_enabled: bool,
|
||||
/// Directories the realtime monitor watches (recursively).
|
||||
pub realtime_watch: Vec<String>,
|
||||
/// What to do when realtime flags a file: "quarantine" | "alert".
|
||||
pub on_detect: String,
|
||||
|
||||
// Ransomware avoidance (write-burst heuristic)
|
||||
pub ransomware_guard: bool,
|
||||
/// File-write events per minute that trips the ransomware alarm.
|
||||
pub ransomware_threshold_per_min: u32,
|
||||
|
||||
// Rootkit detection
|
||||
pub rootkit_enabled: bool,
|
||||
|
||||
// Alerts
|
||||
/// Emit desktop notifications for critical alerts (GUI consumes events).
|
||||
pub notify_desktop: bool,
|
||||
/// Run the signature update automatically (daemon schedules it).
|
||||
pub auto_update_signatures: bool,
|
||||
|
||||
// Global
|
||||
/// Master switch — when true, realtime is suspended and the tray is gray.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
recursive_default: true,
|
||||
max_file_size_mb: 100,
|
||||
exclude_paths: vec!["/proc".into(), "/sys".into(), "/dev".into()],
|
||||
realtime_enabled: true,
|
||||
realtime_watch: vec!["~/Downloads".into()],
|
||||
on_detect: "quarantine".into(),
|
||||
ransomware_guard: true,
|
||||
ransomware_threshold_per_min: 40,
|
||||
rootkit_enabled: true,
|
||||
notify_desktop: true,
|
||||
auto_update_signatures: true,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Merge `incoming` over `self`, but keep fields the client left as
|
||||
/// their defaults if it sent a partially-filled struct. (Kept simple:
|
||||
/// the GUI always sends the full object, so this is a plain copy — but
|
||||
/// the seam is here if we later add sparse updates.)
|
||||
pub fn apply(&mut self, incoming: Settings) {
|
||||
*self = incoming;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events (the alert log) ──────────────────────────────────────────────────
|
||||
|
||||
/// Severity is a plain string on the wire for forward-compat; the daemon
|
||||
/// constrains it to info / warn / critical.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
pub id: u64,
|
||||
/// RFC3339 UTC timestamp.
|
||||
pub ts: String,
|
||||
/// What happened: scan / threat / quarantine / restore / ransomware /
|
||||
/// rootkit / update / realtime / info.
|
||||
pub kind: String,
|
||||
/// info / warn / critical.
|
||||
pub severity: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ── Quarantine ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A file held in the quarantine store (moved out of its original place,
|
||||
/// renamed to a generated name, with a metadata sidecar).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuarantineEntry {
|
||||
/// Stable id (hash of original path + quarantine time).
|
||||
pub id: String,
|
||||
pub original_path: String,
|
||||
/// Where the bytes live now, inside the quarantine store.
|
||||
pub quarantined_path: String,
|
||||
/// Signature that caught it (or "manual").
|
||||
pub virus: String,
|
||||
pub size: u64,
|
||||
/// RFC3339 quarantine time.
|
||||
pub ts: String,
|
||||
/// True after it has been restored to `original_path`.
|
||||
pub restored: bool,
|
||||
}
|
||||
|
||||
// ── Real-time interception ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RealtimeStatus {
|
||||
pub enabled: bool,
|
||||
/// Resolved directories actually being watched right now.
|
||||
pub watching: Vec<String>,
|
||||
/// Total file events observed by the monitor.
|
||||
pub files_seen: u64,
|
||||
/// Files pulled into quarantine by the monitor.
|
||||
pub files_quarantined: u64,
|
||||
/// Last time the monitor saw a file event.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_event_at: Option<String>,
|
||||
/// Seconds since the monitor thread started.
|
||||
pub uptime_secs: u64,
|
||||
/// Whether the inotify loop is currently alive.
|
||||
pub active: bool,
|
||||
/// Last ransomware heuristic state: "calm" | "watching" | "alarm".
|
||||
pub ransomware: String,
|
||||
}
|
||||
|
||||
// ── Rootkit detection ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RootkitFinding {
|
||||
/// Which check flagged this (e.g. "deleted_exe", "world_writable_bin").
|
||||
pub check: String,
|
||||
/// info / warn / critical.
|
||||
pub severity: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RootkitScan {
|
||||
/// Aggregate verdict for the banner: "clean" or "N critical, M warning(s)".
|
||||
pub verdict: String,
|
||||
pub critical: u32,
|
||||
pub warn: u32,
|
||||
pub info: u32,
|
||||
pub findings: Vec<RootkitFinding>,
|
||||
/// RFC3339 scan time.
|
||||
pub ts: String,
|
||||
}
|
||||
|
||||
// ── Client ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A small blocking JSON-RPC client over a Unix socket.
|
||||
|
|
@ -147,12 +303,7 @@ impl Client {
|
|||
}
|
||||
|
||||
/// Connect and issue a single request. Returns the decoded `result`.
|
||||
pub fn call(
|
||||
&self,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
) -> anyhow::Result<Value> {
|
||||
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
|
||||
let mut stream = UnixStream::connect(&self.sock)
|
||||
.map_err(|e| anyhow::anyhow!("cannot reach houndd at {}: {e}", self.sock))?;
|
||||
let req = Request {
|
||||
|
|
@ -196,4 +347,81 @@ impl Client {
|
|||
let v = self.call(3, "update", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
// ── settings ──
|
||||
pub fn settings(&self) -> anyhow::Result<Settings> {
|
||||
let v = self.call(4, "settings.get", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn set_settings(&self, s: &Settings) -> anyhow::Result<Settings> {
|
||||
let v = self.call(5, "settings.set", Some(serde_json::to_value(s)?))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
// ── events ──
|
||||
pub fn events(&self, limit: u32) -> anyhow::Result<Vec<Event>> {
|
||||
let v = self.call(
|
||||
6,
|
||||
"events.list",
|
||||
Some(serde_json::json!({ "limit": limit })),
|
||||
)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn clear_events(&self) -> anyhow::Result<u64> {
|
||||
let v = self.call(7, "events.clear", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
// ── quarantine ──
|
||||
pub fn quarantine_list(&self) -> anyhow::Result<Vec<QuarantineEntry>> {
|
||||
let v = self.call(8, "quarantine.list", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn quarantine_add(&self, path: &str, virus: &str) -> anyhow::Result<QuarantineEntry> {
|
||||
let params = serde_json::json!({ "path": path, "virus": virus });
|
||||
let v = self.call(9, "quarantine.add", Some(params))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn quarantine_restore(&self, id: &str) -> anyhow::Result<QuarantineEntry> {
|
||||
let v = self.call(
|
||||
10,
|
||||
"quarantine.restore",
|
||||
Some(serde_json::json!({ "id": id })),
|
||||
)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn quarantine_remove(&self, id: &str) -> anyhow::Result<u64> {
|
||||
let v = self.call(
|
||||
11,
|
||||
"quarantine.remove",
|
||||
Some(serde_json::json!({ "id": id })),
|
||||
)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
// ── rootkit ──
|
||||
pub fn rootkit_scan(&self) -> anyhow::Result<RootkitScan> {
|
||||
let v = self.call(12, "rootkit.scan", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
// ── realtime ──
|
||||
pub fn realtime_status(&self) -> anyhow::Result<RealtimeStatus> {
|
||||
let v = self.call(13, "realtime.status", None)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
pub fn realtime_set_enabled(&self, enabled: bool) -> anyhow::Result<RealtimeStatus> {
|
||||
let v = self.call(
|
||||
14,
|
||||
"realtime.set_enabled",
|
||||
Some(serde_json::json!({ "enabled": enabled })),
|
||||
)?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use hound_api::{Client, ScanResult, UpdateResult};
|
||||
use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
|
|
@ -52,6 +52,86 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show recent alerts (scan / threat / quarantine / ransomware …)
|
||||
Events {
|
||||
/// How many events to show (default 20)
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: u32,
|
||||
/// Emit machine-readable JSON instead of human text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Clear the alert log
|
||||
ClearEvents,
|
||||
/// Manage the quarantine vault
|
||||
Quarantine {
|
||||
#[command(subcommand)]
|
||||
action: QuarantineCmd,
|
||||
},
|
||||
/// Show or change Hound settings
|
||||
Settings {
|
||||
#[command(subcommand)]
|
||||
action: Option<SettingsCmd>,
|
||||
},
|
||||
/// Run userspace rootkit heuristics
|
||||
Rootkit {
|
||||
/// Emit machine-readable JSON instead of human text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Real-time protection status / on-off toggle
|
||||
Realtime {
|
||||
#[command(subcommand)]
|
||||
action: Option<RealtimeCmd>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum QuarantineCmd {
|
||||
/// List quarantined files
|
||||
List,
|
||||
/// Move a file into quarantine (removes it from its original place)
|
||||
Add {
|
||||
path: String,
|
||||
/// Label to record (default: manual)
|
||||
#[arg(long, default_value = "manual")]
|
||||
virus: String,
|
||||
},
|
||||
/// Restore a quarantined file to its original path
|
||||
Restore { id: String },
|
||||
/// Delete a quarantined file for good (frees its bytes)
|
||||
Remove { id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SettingsCmd {
|
||||
/// Print current settings
|
||||
Show,
|
||||
/// Turn real-time protection on/off
|
||||
Realtime {
|
||||
#[arg(value_parser = ["on", "off"])]
|
||||
mode: String,
|
||||
},
|
||||
/// Pause or resume Hound entirely (tray goes gray)
|
||||
Pause {
|
||||
#[arg(long)]
|
||||
resume: bool,
|
||||
},
|
||||
/// Set the ransomware write-burst threshold (writes per minute)
|
||||
RansomwareThreshold { value: u32 },
|
||||
/// What to do when realtime flags a file: quarantine | alert
|
||||
OnDetect {
|
||||
#[arg(value_parser = ["quarantine", "alert"])]
|
||||
mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum RealtimeCmd {
|
||||
/// Turn the monitor off
|
||||
Off,
|
||||
/// Turn the monitor on
|
||||
On,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -96,7 +176,10 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
|||
println!(" Engine: {}", "present".green());
|
||||
println!(" Signatures:{}", st.db_summary);
|
||||
if let Some(db) = &st.db {
|
||||
println!(" DB file: {} (last modified {})", db.file, db.updated_at);
|
||||
println!(
|
||||
" DB file: {} (last modified {})",
|
||||
db.file, db.updated_at
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
|
|
@ -135,6 +218,181 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
|||
print_update_human(&u);
|
||||
Ok(if u.ok { 0 } else { 1 })
|
||||
}
|
||||
Cmd::Events { limit, json } => {
|
||||
let evs = client.events(*limit)?;
|
||||
if *json {
|
||||
println!("{}", serde_json::to_string_pretty(&evs)?);
|
||||
} else if evs.is_empty() {
|
||||
println!("{} no events logged yet", "—".dimmed());
|
||||
} else {
|
||||
for e in &evs {
|
||||
let sev = match e.severity.as_str() {
|
||||
"critical" => e.severity.red().bold(),
|
||||
"warn" => e.severity.yellow().bold(),
|
||||
_ => e.severity.dimmed(),
|
||||
};
|
||||
println!("{} [{:<8}] {} — {}", e.ts.dimmed(), sev, e.kind, e.message);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::ClearEvents => {
|
||||
let n = client.clear_events()?;
|
||||
println!("{} {} event(s) cleared from the log", "✔".green().bold(), n);
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::Quarantine { action } => match action {
|
||||
QuarantineCmd::List => {
|
||||
let list = client.quarantine_list()?;
|
||||
if list.is_empty() {
|
||||
println!("{} vault is empty", "—".dimmed());
|
||||
} else {
|
||||
for q in &list {
|
||||
let mark = if q.restored {
|
||||
"restored".dimmed().to_string()
|
||||
} else {
|
||||
q.virus.red().to_string()
|
||||
};
|
||||
println!(
|
||||
"{} {} {} ({}, {})",
|
||||
q.id,
|
||||
mark,
|
||||
q.original_path,
|
||||
q.ts,
|
||||
human_size(q.size)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
QuarantineCmd::Add { path, virus } => {
|
||||
let q = client.quarantine_add(path, virus)?;
|
||||
println!(
|
||||
"{} {} → vault ({})",
|
||||
"✔".green().bold(),
|
||||
q.original_path,
|
||||
q.id
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
QuarantineCmd::Restore { id } => {
|
||||
let q = client.quarantine_restore(id)?;
|
||||
println!(
|
||||
"{} restored {} → {}",
|
||||
"✔".green().bold(),
|
||||
q.id,
|
||||
q.original_path
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
QuarantineCmd::Remove { id } => {
|
||||
let freed = client.quarantine_remove(id)?;
|
||||
println!(
|
||||
"{} deleted {} — freed {}",
|
||||
"✔".green().bold(),
|
||||
id,
|
||||
human_size(freed)
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
},
|
||||
Cmd::Settings { action } => {
|
||||
let s = client.settings()?;
|
||||
match action {
|
||||
None | Some(SettingsCmd::Show) => print_settings_human(&s),
|
||||
Some(SettingsCmd::Realtime { mode }) => {
|
||||
let mut next = s.clone();
|
||||
next.realtime_enabled = mode == "on";
|
||||
let s2 = client.set_settings(&next)?;
|
||||
println!(
|
||||
"{} real-time protection {}",
|
||||
"✔".green().bold(),
|
||||
if s2.realtime_enabled {
|
||||
"enabled".green().to_string()
|
||||
} else {
|
||||
"disabled".yellow().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
Some(SettingsCmd::Pause { resume }) => {
|
||||
let mut next = s.clone();
|
||||
next.paused = !*resume;
|
||||
let s2 = client.set_settings(&next)?;
|
||||
println!(
|
||||
"{} Hound {}",
|
||||
"✔".green().bold(),
|
||||
if s2.paused {
|
||||
"paused".yellow().to_string()
|
||||
} else {
|
||||
"resumed".green().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
Some(SettingsCmd::RansomwareThreshold { value }) => {
|
||||
let mut next = s.clone();
|
||||
next.ransomware_threshold_per_min = *value;
|
||||
let s2 = client.set_settings(&next)?;
|
||||
println!(
|
||||
"{} ransomware threshold set to {} writes/min",
|
||||
"✔".green().bold(),
|
||||
s2.ransomware_threshold_per_min
|
||||
);
|
||||
}
|
||||
Some(SettingsCmd::OnDetect { mode }) => {
|
||||
let mut next = s.clone();
|
||||
next.on_detect = mode.clone();
|
||||
let s2 = client.set_settings(&next)?;
|
||||
println!(
|
||||
"{} on-detect action: {}",
|
||||
"✔".green().bold(),
|
||||
s2.on_detect.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::Rootkit { json } => {
|
||||
let r: RootkitScan = client.rootkit_scan()?;
|
||||
if *json {
|
||||
println!("{}", serde_json::to_string_pretty(&r)?);
|
||||
return Ok(0);
|
||||
}
|
||||
print_rootkit_human(&r);
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::Realtime { action } => {
|
||||
match action {
|
||||
None => {
|
||||
let r: RealtimeStatus = client.realtime_status()?;
|
||||
print_realtime_human(&r);
|
||||
}
|
||||
Some(RealtimeCmd::On) => {
|
||||
let r = client.realtime_set_enabled(true)?;
|
||||
println!(
|
||||
"{} real-time protection {}",
|
||||
"✔".green().bold(),
|
||||
if r.active {
|
||||
"on".green().to_string()
|
||||
} else {
|
||||
"enabled (monitor starting)".yellow().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
Some(RealtimeCmd::Off) => {
|
||||
let r = client.realtime_set_enabled(false)?;
|
||||
println!(
|
||||
"{} real-time protection {}",
|
||||
"✔".green().bold(),
|
||||
if r.active {
|
||||
"stopping…".yellow().to_string()
|
||||
} else {
|
||||
"off".dimmed().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,11 +404,7 @@ fn print_update_human(u: &UpdateResult) {
|
|||
u.command
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} update failed via `{}`",
|
||||
"✘".red().bold(),
|
||||
u.command
|
||||
);
|
||||
println!("{} update failed via `{}`", "✘".red().bold(), u.command);
|
||||
}
|
||||
// Show the tail of the run (errors, "already current", etc.).
|
||||
for line in u.output.lines() {
|
||||
|
|
@ -182,6 +436,111 @@ fn print_update_human(u: &UpdateResult) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 1234 → "1.2 KB" etc.
|
||||
fn human_size(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut v = bytes as f64;
|
||||
let mut u = 0;
|
||||
while v >= 1024.0 && u < UNITS.len() - 1 {
|
||||
v /= 1024.0;
|
||||
u += 1;
|
||||
}
|
||||
if u == 0 {
|
||||
format!("{} {}", bytes, UNITS[0])
|
||||
} else {
|
||||
format!("{v:.1} {}", UNITS[u])
|
||||
}
|
||||
}
|
||||
|
||||
fn print_settings_human(s: &Settings) {
|
||||
let on = |b: bool| {
|
||||
if b {
|
||||
"on".green().to_string()
|
||||
} else {
|
||||
"off".dimmed().to_string()
|
||||
}
|
||||
};
|
||||
println!("{} {}", "Hound settings:", "settings:".bold());
|
||||
println!(" Recursion: {}", s.recursive_default);
|
||||
println!(" Max file size: {} MB", s.max_file_size_mb);
|
||||
println!(
|
||||
" Excluded paths: {}",
|
||||
s.exclude_paths.join(", ").dimmed()
|
||||
);
|
||||
println!(" Realtime: {}", on(s.realtime_enabled));
|
||||
println!(
|
||||
" Realtime watches: {}",
|
||||
s.realtime_watch.join(", ").dimmed()
|
||||
);
|
||||
println!(" On detect: {}", s.on_detect.yellow());
|
||||
println!(
|
||||
" Ransomware guard: {} ({} writes/min)",
|
||||
on(s.ransomware_guard),
|
||||
s.ransomware_threshold_per_min
|
||||
);
|
||||
println!(" Rootkit checks: {}", on(s.rootkit_enabled));
|
||||
println!(" Desktop alerts: {}", on(s.notify_desktop));
|
||||
println!(" Auto signature up.: {}", on(s.auto_update_signatures));
|
||||
println!(
|
||||
" Paused: {}",
|
||||
if s.paused {
|
||||
"yes (protection suspended)".yellow().to_string()
|
||||
} else {
|
||||
"no".green().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn print_rootkit_human(r: &RootkitScan) {
|
||||
let verdict = if r.critical > 0 {
|
||||
r.verdict.red().bold().to_string()
|
||||
} else if r.warn > 0 {
|
||||
r.verdict.yellow().bold().to_string()
|
||||
} else {
|
||||
r.verdict.green().bold().to_string()
|
||||
};
|
||||
println!("{} rootkit check — {}", "🐕".to_string(), verdict);
|
||||
println!(
|
||||
" {} critical, {} warning(s), {} info ({})",
|
||||
r.critical,
|
||||
r.warn,
|
||||
r.info,
|
||||
r.ts.dimmed()
|
||||
);
|
||||
if r.findings.is_empty() {
|
||||
println!(" {} no anomalies detected", "✔".green());
|
||||
}
|
||||
for f in &r.findings {
|
||||
let sev = match f.severity.as_str() {
|
||||
"critical" => f.severity.red().bold(),
|
||||
"warn" => f.severity.yellow().bold(),
|
||||
_ => f.severity.dimmed(),
|
||||
};
|
||||
println!(" [{:<8}] {}: {}", sev, f.check, f.detail);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_realtime_human(r: &RealtimeStatus) {
|
||||
let state = if !r.active {
|
||||
"inactive".dimmed().to_string()
|
||||
} else {
|
||||
match r.ransomware.as_str() {
|
||||
"alarm" => "🚨 RANSOMWARE ALARM".red().bold().to_string(),
|
||||
"watching" => "👁 watching (write burst)".yellow().bold().to_string(),
|
||||
_ => "● active — calm".green().bold().to_string(),
|
||||
}
|
||||
};
|
||||
println!("{} {}", "Real-time protection:", state);
|
||||
println!(" Enabled: {}", r.enabled);
|
||||
println!(" Watching: {}", r.watching.join(", ").dimmed());
|
||||
println!(" Files seen: {}", r.files_seen);
|
||||
println!(" Quarantined: {}", r.files_quarantined);
|
||||
println!(" Uptime: {}s", r.uptime_secs);
|
||||
if let Some(last) = &r.last_event_at {
|
||||
println!(" Last event: {}", last.dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human(r: &ScanResult, path: &str) {
|
||||
if r.is_clean() {
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ impl ScanEngine for ClamAvEngine {
|
|||
}
|
||||
// freshclam's DB files live in /var/lib/clamav; report the newest.
|
||||
let mut newest: Option<(String, std::time::SystemTime)> = None;
|
||||
for entry in fs::read_dir("/var/lib/clamav").into_iter().flatten().flatten() {
|
||||
for entry in fs::read_dir("/var/lib/clamav")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path
|
||||
.extension()
|
||||
|
|
@ -86,9 +90,7 @@ impl ScanEngine for ClamAvEngine {
|
|||
.unwrap_or(0);
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"signatures updated {days}d ago ({file}) [clamav {version}]"
|
||||
),
|
||||
format!("signatures updated {days}d ago ({file}) [clamav {version}]"),
|
||||
Some(DbFile {
|
||||
file,
|
||||
updated_at: to_rfc3339(t),
|
||||
|
|
@ -119,7 +121,10 @@ impl ScanEngine for ClamAvEngine {
|
|||
.output()
|
||||
.context("running clamscan (is ClamAV installed?)")?;
|
||||
|
||||
Ok(parse_clamscan(&out.stdout, out.status.code().unwrap_or(-1))?)
|
||||
Ok(parse_clamscan(
|
||||
&out.stdout,
|
||||
out.status.code().unwrap_or(-1),
|
||||
)?)
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<(bool, String, String)> {
|
||||
|
|
@ -207,7 +212,7 @@ pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
|
|||
}
|
||||
|
||||
// 0 = no infections, 1 = infections found, >1 = real error.
|
||||
if !((exit_code == 0 || exit_code == 1)) {
|
||||
if !(exit_code == 0 || exit_code == 1) {
|
||||
anyhow::bail!("clamscan exited {exit_code}");
|
||||
}
|
||||
|
||||
|
|
@ -235,10 +240,79 @@ pub fn to_rfc3339(t: std::time::SystemTime) -> String {
|
|||
dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into())
|
||||
}
|
||||
|
||||
/// Which engine the daemon serves. Flip this when the native engine
|
||||
/// lands — the whole point of the trait is that this is the only change
|
||||
/// the daemon needs.
|
||||
pub const ENGINE: ClamAvEngine = ClamAvEngine;
|
||||
/// The active engine, chosen at daemon startup.
|
||||
///
|
||||
/// Default is [`ClamAvEngine`]. Set `HOUNDD_ENGINE=fake` to the
|
||||
/// [`FakeEngine`] — used by the E2E test so it can drive a full
|
||||
/// daemon lifecycle (status, scan, settings, quarantine, rootkit)
|
||||
/// without requiring ClamAV or a real filesystem of .cld files.
|
||||
///
|
||||
/// The whole point of the trait is that this is the only place the
|
||||
/// daemon decides *which* engine it serves.
|
||||
pub fn engine() -> &'static dyn ScanEngine {
|
||||
if std::env::var_os("HOUNDD_ENGINE").is_some_and(|v| v == "fake") {
|
||||
static FAKE: FakeEngine = FakeEngine;
|
||||
&FAKE
|
||||
} else {
|
||||
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
||||
&CLAMAV
|
||||
}
|
||||
}
|
||||
|
||||
/// Test engine: reports itself present, scans anything whose name
|
||||
/// contains "EICAR" or ".eicar" as infected, and updates cleanly.
|
||||
/// Lets the E2E test exercise the full wire without ClamAV installed.
|
||||
pub struct FakeEngine;
|
||||
|
||||
impl ScanEngine for FakeEngine {
|
||||
fn name(&self) -> &'static str {
|
||||
"fake"
|
||||
}
|
||||
|
||||
fn probe(&self) -> (bool, String, Option<DbFile>) {
|
||||
(
|
||||
true,
|
||||
"signatures: synthetic [fake]".to_string(),
|
||||
Some(DbFile {
|
||||
file: "fake.cld".to_string(),
|
||||
updated_at: to_rfc3339(std::time::SystemTime::now()),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn scan(&self, path: &str, _recursive: bool) -> Result<ScanResult> {
|
||||
use hound_api::Found;
|
||||
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
|
||||
let infected = path
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("eicar");
|
||||
let found = if infected {
|
||||
vec![Found {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
virus: "Fake-Eicar".to_string(),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let scanned: u64 = 1;
|
||||
let infected_u: u64 = if infected { 1 } else { 0 };
|
||||
Ok(ScanResult {
|
||||
scanned,
|
||||
clean: scanned - infected_u,
|
||||
infected: infected_u,
|
||||
found,
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<(bool, String, String)> {
|
||||
Ok((
|
||||
true,
|
||||
"fake update".to_string(),
|
||||
"OK: fake DB refreshed\n".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -316,12 +390,15 @@ mod tests {
|
|||
let (present, _summary, _db) = ClamAvEngine.probe();
|
||||
// CI boxes without ClamAV: presence is whatever the OS says.
|
||||
let via_cmd = Command::new("clamscan").arg("--version").output();
|
||||
assert_eq!(present, via_cmd.map(|o| o.status.success()).unwrap_or(false));
|
||||
assert_eq!(
|
||||
present,
|
||||
via_cmd.map(|o| o.status.success()).unwrap_or(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_is_object_safe() {
|
||||
fn take(_e: &dyn ScanEngine) {}
|
||||
take(&ENGINE);
|
||||
take(engine());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
crates/houndd/src/events.rs
Normal file
134
crates/houndd/src/events.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! The alert/event log — a ring buffer the daemon appends to and the
|
||||
//! GUI/CLI read. This is the "alerts" the user asked for: every threat,
|
||||
//! quarantine, ransomware alarm, rootkit finding, and signature update
|
||||
//! lands here with a severity the GUI can color-code and (optionally)
|
||||
//! surface as a desktop notification.
|
||||
//!
|
||||
//! It is intentionally in-memory (bounded) rather than a growing log file:
|
||||
//! the GUI polls it live, and a bounded buffer is enough for a desktop
|
||||
//! AV's working history. Persistence can be layered on later without
|
||||
//! changing the wire shape.
|
||||
|
||||
use hound_api::Event;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// How many events we keep. 500 is plenty for a working session and keeps
|
||||
/// `events.list` fast.
|
||||
const CAPACITY: usize = 500;
|
||||
|
||||
/// Shared, thread-safe alert log.
|
||||
#[derive(Clone)]
|
||||
pub struct EventLog {
|
||||
inner: Arc<Mutex<Vec<Event>>>,
|
||||
next_id: Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Vec::new())),
|
||||
next_id: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an event, assigning it a monotonically increasing id.
|
||||
/// Returns the new id.
|
||||
pub fn push(&self, kind: &str, severity: &str, message: String) -> u64 {
|
||||
let id = self
|
||||
.next_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let ev = Event {
|
||||
id,
|
||||
ts: now_rfc3339(),
|
||||
kind: kind.to_string(),
|
||||
severity: severity.to_string(),
|
||||
message,
|
||||
};
|
||||
let mut guard = self.inner.lock().unwrap();
|
||||
guard.push(ev);
|
||||
if guard.len() > CAPACITY {
|
||||
let drop = guard.len() - CAPACITY;
|
||||
guard.drain(0..drop);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Most-recent-first list, capped at `limit` (0 = all).
|
||||
pub fn list(&self, limit: u32) -> Vec<Event> {
|
||||
let guard = self.inner.lock().unwrap();
|
||||
let mut v: Vec<Event> = guard.iter().rev().cloned().collect();
|
||||
if limit > 0 {
|
||||
v.truncate(limit as usize);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Drop all events. Returns how many were cleared.
|
||||
pub fn clear(&self) -> u64 {
|
||||
let mut guard = self.inner.lock().unwrap();
|
||||
let n = guard.len();
|
||||
guard.clear();
|
||||
n as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC3339 UTC "now" — reuses the engine's formatter for a single
|
||||
/// timestamp implementation.
|
||||
fn now_rfc3339() -> String {
|
||||
crate::engine::to_rfc3339(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_assigns_increasing_ids() {
|
||||
let log = EventLog::new();
|
||||
let a = log.push("info", "info", "a".into());
|
||||
let b = log.push("threat", "critical", "b".into());
|
||||
assert!(b > a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_most_recent_first_and_capped() {
|
||||
let log = EventLog::new();
|
||||
for i in 0..5 {
|
||||
log.push("info", "info", format!("e{i}"));
|
||||
}
|
||||
let all = log.list(0);
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all[0].message, "e4"); // newest first
|
||||
let two = log.list(2);
|
||||
assert_eq!(two.len(), 2);
|
||||
assert_eq!(two[0].message, "e4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_returns_count() {
|
||||
let log = EventLog::new();
|
||||
log.push("info", "info", "x".into());
|
||||
log.push("info", "info", "y".into());
|
||||
assert_eq!(log.clear(), 2);
|
||||
assert!(log.list(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_bounds_the_buffer() {
|
||||
let log = EventLog::new();
|
||||
for i in 0..(CAPACITY + 10) {
|
||||
log.push("info", "info", format!("e{i}"));
|
||||
}
|
||||
assert_eq!(log.list(0).len(), CAPACITY);
|
||||
// Oldest ones were dropped; newest retained.
|
||||
assert!(log.list(0)[0]
|
||||
.message
|
||||
.ends_with(&(CAPACITY + 9).to_string()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,56 @@
|
|||
//! `houndd` — the Hound engine.
|
||||
//! `houndd` — the Hound Antivirus daemon.
|
||||
//!
|
||||
//! A tiny single-purpose daemon that exposes a scanning engine over a
|
||||
//! Unix-socket, line-delimited JSON-RPC 2.0 API. Both the CLI and the
|
||||
//! GUI are thin clients of this socket, which is what lets future suite
|
||||
//! tools (firewall, updater, …) share the same engine without forking
|
||||
//! it.
|
||||
//! A single-purpose service that exposes a pluggable scanning engine plus a
|
||||
//! set of detection subsystems over a Unix-socket, line-delimited JSON-RPC
|
||||
//! 2.0 API. Both the CLI and the GUI are thin clients of this socket, which
|
||||
//! is what lets future suite tools (firewall, updater, …) share the same
|
||||
//! engine without forking it.
|
||||
//!
|
||||
//! The engine itself is pluggable behind [`engine::ScanEngine`] — today
|
||||
//! that's [`engine::ClamAvEngine`], tomorrow a native Rust engine. The
|
||||
//! wire API doesn't know the difference; it only reports `Status.engine`.
|
||||
//! ## The subsystems
|
||||
//!
|
||||
//! Current methods:
|
||||
//! - `status` → engine health, signature-DB freshness
|
||||
//! - `scan` → recursive scan of a path, per-file findings
|
||||
//! - `update` → refresh the signature store
|
||||
//! - **engine** — signature-scan engine, pluggable behind
|
||||
//! [`engine::ScanEngine`] (ClamAV today, native Rust
|
||||
//! engine tomorrow). Wire API only reports `engine` name.
|
||||
//! - **realtime** — inotify monitor: watches configured dirs, scans each
|
||||
//! new/modified file, quarantines or alerts, and feeds a
|
||||
//! rolling write-window into the **ransomware** heuristic.
|
||||
//! - **quarantine** — the vault: files that got caught are moved here.
|
||||
//! - **rootkit** — userspace rootkit heuristics (hidden pids, hidden
|
||||
//! files, writable system dirs, setuid anomalies).
|
||||
//! - **events** — the alert log every subsystem writes into.
|
||||
//! - **settings** — the user-tunable knobs, persisted to XDG config.
|
||||
//!
|
||||
//! ## RPC methods
|
||||
//! status → engine health + realtime + quarantine counts
|
||||
//! scan → recursive scan of a path, per-file findings
|
||||
//! update → refresh the signature store
|
||||
//! settings.get → current knobs
|
||||
//! settings.set → replace the knobs (live-reload realtime)
|
||||
//! events.list → the alert log (newest first)
|
||||
//! events.clear → drop all alerts
|
||||
//! quarantine.list → files held in quarantine
|
||||
//! quarantine.add → move a file into the vault
|
||||
//! quarantine.restore→ put a file back
|
||||
//! quarantine.remove → delete a quarantined file
|
||||
//! rootkit.scan → run the rootkit heuristics
|
||||
//! realtime.status → what's watched, counters, ransomware state
|
||||
//! realtime.set_enabled → toggle the monitor
|
||||
//!
|
||||
//! The engine is selectable at startup for tests: `HOUNDD_ENGINE=fake`
|
||||
//! swaps in [`engine::engine`]'s fake backend (a full daemon lifecycle
|
||||
//! without ClamAV installed).
|
||||
|
||||
mod engine;
|
||||
mod events;
|
||||
mod quarantine;
|
||||
mod realtime;
|
||||
mod rootkit;
|
||||
mod settings;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use engine::{ScanEngine, ENGINE};
|
||||
use hound_api::Response;
|
||||
use hound_api::{Response, Settings};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
|
@ -28,6 +59,17 @@ use std::path::PathBuf;
|
|||
|
||||
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Shared, daemon-lifetime state. Cheap to clone (everything is `Arc`-shared),
|
||||
/// so the accept loop hands a copy to each connection thread and the realtime
|
||||
/// monitor holds its own copy.
|
||||
#[derive(Clone)]
|
||||
struct DaemonState {
|
||||
settings: settings::SettingsStore,
|
||||
events: events::EventLog,
|
||||
quarantine: quarantine::Quarantine,
|
||||
realtime: realtime::RealtimeMonitor,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let sock = hound_api::default_socket_path();
|
||||
let sock_path = PathBuf::from(&sock);
|
||||
|
|
@ -37,11 +79,13 @@ fn main() -> Result<()> {
|
|||
}
|
||||
// Reconnect-friendly startup: drop a stale socket from a dead daemon.
|
||||
let _ = fs::remove_file(&sock_path);
|
||||
let listener = UnixListener::bind(&sock_path)
|
||||
.with_context(|| format!("binding {sock}"))?;
|
||||
let listener = UnixListener::bind(&sock_path).with_context(|| format!("binding {sock}"))?;
|
||||
|
||||
let state = DaemonState::boot();
|
||||
|
||||
eprintln!(
|
||||
"houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)",
|
||||
ENGINE.name()
|
||||
engine::engine().name()
|
||||
);
|
||||
|
||||
for stream in listener.incoming() {
|
||||
|
|
@ -52,8 +96,9 @@ fn main() -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
let state = state.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = handle_conn(stream) {
|
||||
if let Err(e) = handle_conn(stream, state) {
|
||||
eprintln!("connection error: {e}");
|
||||
}
|
||||
});
|
||||
|
|
@ -61,16 +106,50 @@ fn main() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
impl DaemonState {
|
||||
/// Build the shared state and start the realtime monitor.
|
||||
///
|
||||
/// The monitor is started lazily: `start()` is idempotent and reads the
|
||||
/// watch dirs from settings at the moment it's called. We start it here
|
||||
/// so the daemon is protective from boot; a `settings.set` that changes
|
||||
/// the watch list calls `start()` again (a no-op if already running —
|
||||
/// the thread picks up new dirs via its own settings reads).
|
||||
fn boot() -> Self {
|
||||
let settings = settings::SettingsStore::load();
|
||||
let events = events::EventLog::new();
|
||||
let quarantine = quarantine::Quarantine::new();
|
||||
let realtime =
|
||||
realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone());
|
||||
|
||||
// If realtime is enabled, bring the monitor up. Failures are
|
||||
// non-fatal (e.g. no inotify) — the daemon still serves scans.
|
||||
let s = settings.get();
|
||||
if s.realtime_enabled && !s.paused {
|
||||
match realtime.start() {
|
||||
Ok(()) => events.push("realtime", "info", "real-time monitor started".into()),
|
||||
Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")),
|
||||
};
|
||||
}
|
||||
events.push("info", "info", "houndd daemon started".into());
|
||||
|
||||
Self {
|
||||
settings,
|
||||
events,
|
||||
quarantine,
|
||||
realtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one request line, dispatch, write one response line.
|
||||
fn handle_conn(stream: UnixStream) -> Result<()> {
|
||||
fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let req: hound_api::Request = serde_json::from_str(line.trim())
|
||||
.context("decoding request")?;
|
||||
let req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?;
|
||||
|
||||
let result = dispatch(&req);
|
||||
let result = dispatch(&req, &state);
|
||||
let resp = match result {
|
||||
Ok(value) => Response {
|
||||
jsonrpc: "2.0".into(),
|
||||
|
|
@ -103,10 +182,10 @@ fn writer_flush(reader: &mut BufReader<UnixStream>, bytes: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch(req: &hound_api::Request) -> Result<Value> {
|
||||
fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
||||
match req.method.as_str() {
|
||||
"status" => Ok(serde_json::to_value(status())?),
|
||||
"update" => Ok(serde_json::to_value(update()?)?),
|
||||
"status" => Ok(serde_json::to_value(status(st)?)?),
|
||||
"update" => Ok(serde_json::to_value(update(st)?)?),
|
||||
"scan" => {
|
||||
let path = req
|
||||
.params
|
||||
|
|
@ -122,48 +201,179 @@ fn dispatch(req: &hound_api::Request) -> Result<Value> {
|
|||
.unwrap_or(true);
|
||||
Ok(serde_json::to_value(scan(path, recursive)?)?)
|
||||
}
|
||||
|
||||
// ── settings ──
|
||||
"settings.get" => Ok(serde_json::to_value(st.settings.get())?),
|
||||
"settings.set" => {
|
||||
let incoming: Settings = serde_json::from_value(
|
||||
req.params
|
||||
.clone()
|
||||
.context("settings.set requires a params object")?,
|
||||
)?;
|
||||
st.settings
|
||||
.set(&incoming)
|
||||
.map_err(|e| anyhow::anyhow!("persisting settings: {e}"))?;
|
||||
|
||||
// Live-reload the realtime monitor: if it's not running and the
|
||||
// new settings ask for it, start it; if the watch list changed,
|
||||
// the thread's per-event settings reads pick it up.
|
||||
let cur = st.settings.get();
|
||||
if cur.realtime_enabled && !cur.paused && !st.realtime.is_running() {
|
||||
let _ = st.realtime.start();
|
||||
}
|
||||
Ok(serde_json::to_value(cur)?)
|
||||
}
|
||||
|
||||
// ── events / alerts ──
|
||||
"events.list" => {
|
||||
let limit = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("limit"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(100) as u32;
|
||||
Ok(serde_json::to_value(st.events.list(limit))?)
|
||||
}
|
||||
"events.clear" => Ok(serde_json::to_value(st.events.clear())?),
|
||||
|
||||
// ── quarantine ──
|
||||
"quarantine.list" => Ok(serde_json::to_value(st.quarantine.list())?),
|
||||
"quarantine.add" => {
|
||||
let path = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(Value::as_str)
|
||||
.context("quarantine.add requires params.path")?;
|
||||
let virus = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("virus"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("manual");
|
||||
let entry = realtime::quarantine_and_log(&st.quarantine, &st.events, path, virus)
|
||||
.with_context(|| format!("quarantining {path}"))?;
|
||||
Ok(serde_json::to_value(entry)?)
|
||||
}
|
||||
"quarantine.restore" => {
|
||||
let id = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.context("quarantine.restore requires params.id")?;
|
||||
let entry = st
|
||||
.quarantine
|
||||
.restore(id)
|
||||
.with_context(|| format!("restoring {id}"))?;
|
||||
st.events.push(
|
||||
"restore",
|
||||
"info",
|
||||
format!("restored {} from quarantine", entry.original_path),
|
||||
);
|
||||
Ok(serde_json::to_value(entry)?)
|
||||
}
|
||||
"quarantine.remove" => {
|
||||
let id = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.context("quarantine.remove requires params.id")?;
|
||||
let freed = st.quarantine.remove(id)?;
|
||||
st.events.push(
|
||||
"quarantine",
|
||||
"info",
|
||||
format!("deleted quarantined file {id} ({freed} bytes)"),
|
||||
);
|
||||
Ok(serde_json::to_value(freed)?)
|
||||
}
|
||||
|
||||
// ── rootkit ──
|
||||
"rootkit.scan" => {
|
||||
let dirs = st.settings.get().realtime_watch.clone();
|
||||
let scan = rootkit::run_scan(&dirs);
|
||||
let sev = if scan.critical > 0 {
|
||||
"critical"
|
||||
} else if scan.warn > 0 {
|
||||
"warn"
|
||||
} else {
|
||||
"info"
|
||||
};
|
||||
st.events
|
||||
.push("rootkit", sev, format!("rootkit scan: {}", scan.verdict));
|
||||
Ok(serde_json::to_value(scan)?)
|
||||
}
|
||||
|
||||
// ── realtime ──
|
||||
"realtime.status" => Ok(serde_json::to_value(st.realtime.status())?),
|
||||
"realtime.set_enabled" => {
|
||||
let enabled = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("enabled"))
|
||||
.and_then(Value::as_bool)
|
||||
.context("realtime.set_enabled requires params.enabled")?;
|
||||
let mut s = st.settings.get();
|
||||
s.realtime_enabled = enabled;
|
||||
let updated = st.settings.mutate(|s2| *s2 = s);
|
||||
if enabled && !updated.paused {
|
||||
let _ = st.realtime.start();
|
||||
} else if !enabled {
|
||||
st.realtime.stop();
|
||||
}
|
||||
Ok(serde_json::to_value(st.realtime.status())?)
|
||||
}
|
||||
|
||||
other => bail!("unknown method {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC handlers (engine-agnostic) ──────────────────────────────────────────
|
||||
|
||||
fn status() -> hound_api::Status {
|
||||
let (present, db_summary, db) = ENGINE.probe();
|
||||
fn status(st: &DaemonState) -> Result<hound_api::Status> {
|
||||
let (present, db_summary, db) = engine::engine().probe();
|
||||
let os = std::fs::read_to_string("/etc/os-release")
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
c.lines()
|
||||
.find(|l| l.starts_with("PRETTY_NAME="))
|
||||
.map(|l| {
|
||||
l.trim_start_matches("PRETTY_NAME=")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
})
|
||||
c.lines().find(|l| l.starts_with("PRETTY_NAME=")).map(|l| {
|
||||
l.trim_start_matches("PRETTY_NAME=")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
hound_api::Status {
|
||||
Ok(hound_api::Status {
|
||||
daemon_version: DAEMON_VERSION.to_string(),
|
||||
engine: ENGINE.name().to_string(),
|
||||
engine: engine::engine().name().to_string(),
|
||||
engine_present: present,
|
||||
db_summary,
|
||||
os,
|
||||
db,
|
||||
}
|
||||
realtime: st.realtime.status(),
|
||||
quarantined: st.quarantine.count(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh the signature store via the engine, then re-probe so the
|
||||
/// client can refresh its UI/tray from a single round-trip.
|
||||
fn update() -> Result<hound_api::UpdateResult> {
|
||||
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
|
||||
use hound_api::UpdateResult;
|
||||
|
||||
let (ok, command, combined) = ENGINE.update()?;
|
||||
|
||||
let (ok, command, combined) = engine::engine().update()?;
|
||||
st.events.push(
|
||||
"update",
|
||||
if ok { "info" } else { "warn" },
|
||||
format!(
|
||||
"signature update via {command}: {}",
|
||||
if ok { "ok" } else { "failed" }
|
||||
),
|
||||
);
|
||||
Ok(UpdateResult {
|
||||
ok,
|
||||
command,
|
||||
output: cap_tail(&combined, 2048),
|
||||
status: status(),
|
||||
status: status(st)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +394,7 @@ fn cap_tail(s: &str, max: usize) -> String {
|
|||
}
|
||||
|
||||
fn scan(path: &str, recursive: bool) -> Result<hound_api::ScanResult> {
|
||||
ENGINE.scan(path, recursive)
|
||||
engine::engine().scan(path, recursive)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -193,8 +403,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn client_type_is_constructible() {
|
||||
let _c: hound_api::Client =
|
||||
hound_api::Client::new("/tmp/does-not-matter.sock".into());
|
||||
let _c: hound_api::Client = hound_api::Client::new("/tmp/does-not-matter.sock".into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -204,4 +413,127 @@ mod tests {
|
|||
assert!(t.len() <= 100 + 4); // "…\n" prefix is 4 bytes
|
||||
assert!(t.starts_with('…'));
|
||||
}
|
||||
|
||||
/// Full daemon lifecycle with the fake engine: boot, query status,
|
||||
/// round-trip settings, quarantine a file, run the rootkit scan, and
|
||||
/// confirm the realtime status shape — all over a real Unix socket.
|
||||
#[test]
|
||||
fn e2e_fake_engine_over_socket() {
|
||||
use serde_json::json;
|
||||
use std::io::{BufRead, Write as _};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("hound-e2e-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let sock = dir.join("houndd.sock");
|
||||
let cfg = dir.join("cfg");
|
||||
let data = dir.join("data");
|
||||
|
||||
// Serialize against any other test that mutates process env.
|
||||
let _env_guard = crate::test_util::locked();
|
||||
|
||||
// Isolate every path the daemon touches.
|
||||
std::env::set_var("HOUNDD_ENGINE", "fake");
|
||||
std::env::set_var("HOUNDD_SOCK", &sock);
|
||||
std::env::set_var("XDG_CONFIG_HOME", &cfg);
|
||||
std::env::set_var("XDG_DATA_HOME", &data);
|
||||
|
||||
// Boot the listener on a thread (the monitor also starts from here).
|
||||
let listener = UnixListener::bind(&sock).unwrap();
|
||||
let state = DaemonState::boot();
|
||||
let shutdown_state = state.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
if let Ok(s) = stream {
|
||||
let state = state.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = handle_conn(s, state);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// status — engine present, realtime active, quarantine 0.
|
||||
{
|
||||
let mut c = std::io::BufReader::new(UnixStream::connect(&sock).unwrap());
|
||||
let req = json!({"jsonrpc":"2.0","id":1,"method":"status"});
|
||||
c.get_mut()
|
||||
.write_all((serde_json::to_string(&req).unwrap() + "\n").as_bytes())
|
||||
.unwrap();
|
||||
let mut line = String::new();
|
||||
c.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(line.trim()).unwrap();
|
||||
let r = v["result"].clone();
|
||||
assert_eq!(r["engine"].as_str().unwrap(), "fake");
|
||||
assert!(r["engine_present"].as_bool().unwrap());
|
||||
assert!(r["realtime"].is_object());
|
||||
assert_eq!(r["quarantined"].as_u64().unwrap(), 0);
|
||||
}
|
||||
|
||||
// settings.get then settings.set (round-trip a changed knob).
|
||||
{
|
||||
let client = hound_api::Client::new(sock.to_str().unwrap().to_string());
|
||||
let mut s = client.settings().unwrap();
|
||||
assert!(s.recursive_default);
|
||||
s.max_file_size_mb = 42;
|
||||
let back = client.set_settings(&s).unwrap();
|
||||
assert_eq!(back.max_file_size_mb, 42);
|
||||
assert_eq!(client.settings().unwrap().max_file_size_mb, 42);
|
||||
}
|
||||
|
||||
// quarantine.add + list + restore.
|
||||
{
|
||||
let client = hound_api::Client::new(sock.to_str().unwrap().to_string());
|
||||
let f = dir.join("victim.bin");
|
||||
std::fs::write(&f, b"evil").unwrap();
|
||||
let entry = client
|
||||
.quarantine_add(f.to_str().unwrap(), "EICAR-Test")
|
||||
.unwrap();
|
||||
assert!(!entry.restored);
|
||||
assert!(!f.exists());
|
||||
assert_eq!(client.quarantine_list().unwrap().len(), 1);
|
||||
let restored = client.quarantine_restore(&entry.id).unwrap();
|
||||
assert!(restored.restored);
|
||||
assert!(f.exists());
|
||||
}
|
||||
|
||||
// rootkit.scan returns a well-formed shape.
|
||||
{
|
||||
let client = hound_api::Client::new(sock.to_str().unwrap().to_string());
|
||||
let scan = client.rootkit_scan().unwrap();
|
||||
assert!(scan.verdict.contains("clean") || scan.critical + scan.warn + scan.info > 0);
|
||||
let total = scan.findings.len() as u32;
|
||||
assert_eq!(scan.critical + scan.warn + scan.info, total);
|
||||
}
|
||||
|
||||
// events reflect what happened (at least the boot + scan events).
|
||||
{
|
||||
let client = hound_api::Client::new(sock.to_str().unwrap().to_string());
|
||||
let evts = client.events(100).unwrap();
|
||||
assert!(!evts.is_empty());
|
||||
// Most-recent-first: the rootkit scan is one of the recent events.
|
||||
assert!(evts.iter().any(|e| e.kind == "rootkit"));
|
||||
}
|
||||
|
||||
// realtime.status shape is intact.
|
||||
{
|
||||
let client = hound_api::Client::new(sock.to_str().unwrap().to_string());
|
||||
let rt = client.realtime_status().unwrap();
|
||||
assert!(rt.uptime_secs < 60);
|
||||
}
|
||||
|
||||
// Clean shutdown: stop the monitor (drops the inotify loop) then
|
||||
// drop the listener so the accept thread can exit.
|
||||
shutdown_state.realtime.stop();
|
||||
drop(shutdown_state);
|
||||
// The listener thread blocks on incoming(); sending a signal isn't
|
||||
// portable, so just detach it (daemon is in a test process).
|
||||
let _ = handle;
|
||||
|
||||
std::env::remove_var("HOUNDD_ENGINE");
|
||||
std::env::remove_var("HOUNDD_SOCK");
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
227
crates/houndd/src/quarantine.rs
Normal file
227
crates/houndd/src/quarantine.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//! Quarantine — the vault where Hound keeps files it caught.
|
||||
//!
|
||||
//! A quarantined file is *moved* (not copied) into the store under a
|
||||
//! generated name, so the original location no longer holds the threat.
|
||||
//! Each entry has a JSON sidecar holding the original path, the signature
|
||||
//! that caught it, the quarantine time, and whether it has been restored.
|
||||
//!
|
||||
//! Store layout:
|
||||
//! $XDG_DATA_HOME/hound/quarantine/
|
||||
//! <id> ← the file's bytes
|
||||
//! <id>.meta.json ← QuarantineEntry metadata
|
||||
//!
|
||||
//! Restoring moves the bytes back to the original path (recreating parent
|
||||
//! dirs if needed) and keeps the entry flagged `restored: true` so the UI
|
||||
//! can show it was let back out. Removing is a hard delete.
|
||||
|
||||
use hound_api::QuarantineEntry;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Where the quarantine store lives for this user.
|
||||
pub fn store_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let base = match data {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
PathBuf::from(home).join(".local").join("share")
|
||||
}
|
||||
};
|
||||
base.join("hound").join("quarantine")
|
||||
}
|
||||
|
||||
/// Thread-safe view over the on-disk quarantine store. The in-memory
|
||||
/// cache keeps `list()` cheap; every mutation also rewrites the sidecar.
|
||||
#[derive(Clone)]
|
||||
pub struct Quarantine {
|
||||
#[allow(dead_code)] // held so two Quarantine instances share a cache
|
||||
cache: Arc<Mutex<Vec<QuarantineEntry>>>,
|
||||
}
|
||||
|
||||
impl Default for Quarantine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Quarantine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all quarantined entries, newest first.
|
||||
pub fn list(&self) -> Vec<QuarantineEntry> {
|
||||
let dir = store_dir();
|
||||
let mut entries: Vec<QuarantineEntry> = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
if let Ok(meta) = std::fs::read_to_string(&path) {
|
||||
if let Ok(e) = serde_json::from_str::<QuarantineEntry>(&meta) {
|
||||
entries.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
*self.cache.lock().unwrap() = entries.clone();
|
||||
entries
|
||||
}
|
||||
|
||||
/// Move `path` into the store. `virus` is the signature that caught it
|
||||
/// (or "manual" when the user quarantines by hand). Returns the entry.
|
||||
pub fn add(&self, path: &str, virus: &str) -> anyhow::Result<QuarantineEntry> {
|
||||
let src = std::fs::canonicalize(path)
|
||||
.map_err(|e| anyhow::anyhow!("no such file to quarantine {path}: {e}"))?;
|
||||
let dir = store_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let id = make_id(&src);
|
||||
let dest = dir.join(&id);
|
||||
let meta_path = dir.join(format!("{id}.meta.json"));
|
||||
|
||||
// Move the bytes in.
|
||||
std::fs::rename(&src, &dest)?;
|
||||
|
||||
let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
|
||||
let entry = QuarantineEntry {
|
||||
id: id.clone(),
|
||||
original_path: src.to_string_lossy().to_string(),
|
||||
quarantined_path: dest.to_string_lossy().to_string(),
|
||||
virus: virus.to_string(),
|
||||
size,
|
||||
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
|
||||
restored: false,
|
||||
};
|
||||
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Restore a quarantined file to its original path.
|
||||
pub fn restore(&self, id: &str) -> anyhow::Result<QuarantineEntry> {
|
||||
let dir = store_dir();
|
||||
let file = dir.join(id);
|
||||
let meta_path = dir.join(format!("{id}.meta.json"));
|
||||
let meta = std::fs::read_to_string(&meta_path)?;
|
||||
let mut entry: QuarantineEntry = serde_json::from_str(&meta)?;
|
||||
|
||||
let dest = PathBuf::from(&entry.original_path);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::rename(&file, &dest)?;
|
||||
|
||||
entry.restored = true;
|
||||
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Hard-delete a quarantined file (bytes + sidecar). Returns bytes freed.
|
||||
pub fn remove(&self, id: &str) -> anyhow::Result<u64> {
|
||||
let dir = store_dir();
|
||||
let file = dir.join(id);
|
||||
let meta_path = dir.join(format!("{id}.meta.json"));
|
||||
let bytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0);
|
||||
let _ = std::fs::remove_file(&file);
|
||||
let _ = std::fs::remove_file(&meta_path);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Number of non-restored entries.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.list().into_iter().filter(|e| !e.restored).count() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable-ish id from the original path + a time component so two quarrantines
|
||||
/// of the same file at different times get distinct ids.
|
||||
fn make_id(path: &std::path::Path) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
// FNV-1a over path + nanos → 16 hex chars.
|
||||
let input = format!("{}:{}", path.display(), now);
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for b in input.bytes() {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Per-test data dir (tag it so tests never share a directory — one
|
||||
/// test's cleanup must not race another's file writes).
|
||||
fn tmp_data(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("hound-qt-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_then_list_then_remove() {
|
||||
let data = tmp_data("addremove");
|
||||
let _env_guard = crate::test_util::locked();
|
||||
std::env::set_var("XDG_DATA_HOME", &data);
|
||||
let src = data.join("victim.bin");
|
||||
std::fs::write(&src, b"pay attention").unwrap();
|
||||
|
||||
let q = Quarantine::new();
|
||||
let entry = q.add(src.to_str().unwrap(), "Test.Virus").unwrap();
|
||||
assert_eq!(entry.virus, "Test.Virus");
|
||||
assert!(!entry.restored);
|
||||
|
||||
// Original is gone, bytes are in the store.
|
||||
assert!(!src.exists());
|
||||
assert!(std::path::Path::new(&entry.quarantined_path).exists());
|
||||
|
||||
let list = q.list();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].id, entry.id);
|
||||
|
||||
let freed = q.remove(&entry.id).unwrap();
|
||||
assert_eq!(freed, 13);
|
||||
assert!(q.list().is_empty());
|
||||
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
let _ = std::fs::remove_dir_all(&data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_puts_file_back() {
|
||||
let data = tmp_data("restore");
|
||||
let _env_guard = crate::test_util::locked();
|
||||
std::env::set_var("XDG_DATA_HOME", &data);
|
||||
let src = data.join("back.bin");
|
||||
std::fs::write(&src, b"hello").unwrap();
|
||||
|
||||
let q = Quarantine::new();
|
||||
let entry = q.add(src.to_str().unwrap(), "Manual").unwrap();
|
||||
let restored = q.restore(&entry.id).unwrap();
|
||||
assert!(restored.restored);
|
||||
assert!(src.exists());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"hello");
|
||||
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
let _ = std::fs::remove_dir_all(&data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_distinct() {
|
||||
let a = make_id(std::path::Path::new("/tmp/x"));
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
let b = make_id(std::path::Path::new("/tmp/x"));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
505
crates/houndd/src/realtime.rs
Normal file
505
crates/houndd/src/realtime.rs
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
//! Real-time interception.
|
||||
//!
|
||||
//! A background thread owns an inotify instance watching the configured
|
||||
//! directories (recursively — we walk each dir and add a watch per
|
||||
//! subdirectory, and add a watch when a new directory appears). For every
|
||||
//! file-appearing / file-written event it:
|
||||
//!
|
||||
//! 1. increments the "files seen" counter,
|
||||
//! 2. skips excluded paths,
|
||||
//! 3. runs the engine on that one file,
|
||||
//! 4. on a hit, either quarantines it (default) or just raises an alert,
|
||||
//! 5. feeds a rolling 60-second window of write events into the
|
||||
//! **ransomware heuristic** — a burst of writes past the configured
|
||||
//! per-minute threshold raises a ransomware alarm.
|
||||
//!
|
||||
//! The engine, quarantine store, event log, and settings are all
|
||||
//! `Arc`-shared, so the monitor and the RPC threads cooperate without
|
||||
//! locking the world.
|
||||
|
||||
use hound_api::{QuarantineEntry, RealtimeStatus, Settings};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::engine::engine;
|
||||
use crate::events::EventLog;
|
||||
use crate::quarantine::Quarantine;
|
||||
use crate::settings::SettingsStore;
|
||||
use inotify::{EventMask, Inotify, WatchDescriptor, WatchMask};
|
||||
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Mutable state the monitor writes and `status()` reads.
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
files_seen: u64,
|
||||
files_quarantined: u64,
|
||||
last_event_at: Option<String>,
|
||||
ransomware: String,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ransomware: "calm".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The real-time monitor. Cheaply cloned (all state is shared).
|
||||
pub struct RealtimeMonitor {
|
||||
counters: Arc<Mutex<Counters>>,
|
||||
started_at: Arc<Mutex<Option<SystemTime>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
active: Arc<AtomicBool>,
|
||||
handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
|
||||
watch_dirs: Arc<Mutex<Vec<PathBuf>>>,
|
||||
settings: SettingsStore,
|
||||
quarantine: Quarantine,
|
||||
events: EventLog,
|
||||
}
|
||||
|
||||
impl Clone for RealtimeMonitor {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
counters: Arc::clone(&self.counters),
|
||||
started_at: Arc::clone(&self.started_at),
|
||||
running: Arc::clone(&self.running),
|
||||
active: Arc::clone(&self.active),
|
||||
handle: Arc::clone(&self.handle),
|
||||
watch_dirs: Arc::clone(&self.watch_dirs),
|
||||
settings: self.settings.clone(),
|
||||
quarantine: self.quarantine.clone(),
|
||||
events: self.events.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealtimeMonitor {
|
||||
pub fn new(settings: SettingsStore, quarantine: Quarantine, events: EventLog) -> Self {
|
||||
Self {
|
||||
counters: Arc::new(Mutex::new(Counters::new())),
|
||||
started_at: Arc::new(Mutex::new(None)),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
active: Arc::new(AtomicBool::new(false)),
|
||||
handle: Arc::new(Mutex::new(None)),
|
||||
watch_dirs: Arc::new(Mutex::new(Vec::new())),
|
||||
settings,
|
||||
quarantine,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn watch_dirs(&self) -> Vec<PathBuf> {
|
||||
self.watch_dirs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RealtimeStatus {
|
||||
let s = self.settings.get();
|
||||
let c = self.counters.lock().unwrap();
|
||||
let started = *self.started_at.lock().unwrap();
|
||||
let uptime = started
|
||||
.and_then(|t| SystemTime::now().duration_since(t).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
RealtimeStatus {
|
||||
enabled: s.realtime_enabled && !s.paused,
|
||||
watching: self
|
||||
.watch_dirs()
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect(),
|
||||
files_seen: c.files_seen,
|
||||
files_quarantined: c.files_quarantined,
|
||||
last_event_at: c.last_event_at.clone(),
|
||||
uptime_secs: uptime,
|
||||
active: self.is_active(),
|
||||
ransomware: c.ransomware.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the monitor thread if it isn't already running. Idempotent.
|
||||
pub fn start(&self) -> Result<(), String> {
|
||||
if self.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
// Expand configured watch dirs (tildes) to real paths.
|
||||
let s = self.settings.get();
|
||||
let dirs: Vec<PathBuf> = s
|
||||
.realtime_watch
|
||||
.iter()
|
||||
.filter_map(|d| resolve_dir(d))
|
||||
.collect();
|
||||
*self.watch_dirs.lock().unwrap() = dirs.clone();
|
||||
|
||||
if !self.is_active() {
|
||||
*self.started_at.lock().unwrap() = Some(SystemTime::now());
|
||||
}
|
||||
self.running.store(true, Ordering::Relaxed);
|
||||
|
||||
let counters = Arc::clone(&self.counters);
|
||||
let running = Arc::clone(&self.running);
|
||||
let active = Arc::clone(&self.active);
|
||||
let settings = self.settings.clone();
|
||||
let quarantine = self.quarantine.clone();
|
||||
let events = self.events.clone();
|
||||
let watch_dirs = Arc::clone(&self.watch_dirs);
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name("houndd-realtime".into())
|
||||
.spawn(move || {
|
||||
run_monitor(
|
||||
dirs,
|
||||
&counters,
|
||||
&running,
|
||||
&active,
|
||||
&settings,
|
||||
&quarantine,
|
||||
&events,
|
||||
&watch_dirs,
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
*self.handle.lock().unwrap() = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the monitor thread (sets the running flag; the loop exits at
|
||||
/// the next 1s tick).
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
let h = self.handle.lock().unwrap().take();
|
||||
if let Some(h) = h {
|
||||
let _ = h.join();
|
||||
}
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_monitor(
|
||||
initial_dirs: Vec<PathBuf>,
|
||||
counters: &Arc<Mutex<Counters>>,
|
||||
running: &Arc<AtomicBool>,
|
||||
active: &Arc<AtomicBool>,
|
||||
settings: &SettingsStore,
|
||||
quarantine: &Quarantine,
|
||||
events: &EventLog,
|
||||
watch_dirs: &Arc<Mutex<Vec<PathBuf>>>,
|
||||
) {
|
||||
let Ok(mut inotify) = Inotify::init() else {
|
||||
eprintln!("realtime: inotify init failed — monitor idle");
|
||||
return;
|
||||
};
|
||||
// inotify 0.10 has no wd→path lookup, so we keep our own map.
|
||||
let mut wd_map: HashMap<WatchDescriptor, PathBuf> = HashMap::new();
|
||||
let mask = WatchMask::CREATE | WatchMask::CLOSE_WRITE | WatchMask::MOVED_TO;
|
||||
for d in &initial_dirs {
|
||||
add_recursive_watches(&mut inotify, d, mask, &mut wd_map);
|
||||
}
|
||||
active.store(true, Ordering::Relaxed);
|
||||
eprintln!(
|
||||
"realtime: watching {} dir(s), {} watch(es)",
|
||||
initial_dirs.len(),
|
||||
wd_map.len()
|
||||
);
|
||||
|
||||
let mut buf = [0u8; 16_384];
|
||||
let mut write_window: VecDeque<SystemTime> = VecDeque::new();
|
||||
let engine = engine();
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
let evts = match inotify.read_events(&mut buf) {
|
||||
Ok(iter) => iter.collect::<Vec<_>>(),
|
||||
// Non-blocking fd: nothing queued right now — sleep and retry.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("realtime: inotify read error: {e} — backing off");
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if evts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
for ev in &evts {
|
||||
let (is_write, is_new_dir, path) = match classify(ev, &wd_map) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let mut c = counters.lock().unwrap();
|
||||
if is_new_dir {
|
||||
// A new directory inside a watched dir → watch it too.
|
||||
if let Ok(wd) = inotify.watches().add(&path, mask) {
|
||||
wd_map.insert(wd, path.clone());
|
||||
watch_dirs.lock().unwrap().push(path.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !is_write {
|
||||
continue;
|
||||
}
|
||||
// File write/move event.
|
||||
c.files_seen += 1;
|
||||
c.last_event_at = Some(crate::engine::to_rfc3339(now));
|
||||
drop(c);
|
||||
|
||||
let s = settings.get();
|
||||
if s.paused || !s.realtime_enabled {
|
||||
continue;
|
||||
}
|
||||
// Ransomware write-burst heuristic.
|
||||
write_window.push_back(now);
|
||||
while let Some(front) = write_window.front() {
|
||||
if now.duration_since(*front).unwrap_or_default() > WINDOW {
|
||||
write_window.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if s.ransomware_guard && (write_window.len() as u32) >= s.ransomware_threshold_per_min {
|
||||
let mut c = counters.lock().unwrap();
|
||||
if c.ransomware != "alarm" {
|
||||
c.ransomware = "alarm".into();
|
||||
}
|
||||
drop(c);
|
||||
events.push(
|
||||
"ransomware",
|
||||
"critical",
|
||||
format!(
|
||||
"write burst: {} file writes in 60s (threshold {}) — possible ransomware",
|
||||
write_window.len(),
|
||||
s.ransomware_threshold_per_min
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if excluded(&path, &s.exclude_paths) {
|
||||
continue;
|
||||
}
|
||||
// Scan this single file with the engine.
|
||||
match engine.scan(path.to_str().unwrap_or(""), false) {
|
||||
Ok(r) if !r.is_clean() => {
|
||||
let found = r.found.first().cloned();
|
||||
let virus = found
|
||||
.as_ref()
|
||||
.map(|f| f.virus.clone())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
if s.on_detect == "quarantine" {
|
||||
match quarantine.add(path.to_str().unwrap_or(""), &virus) {
|
||||
Ok(entry) => {
|
||||
let mut c = counters.lock().unwrap();
|
||||
c.files_quarantined += 1;
|
||||
drop(c);
|
||||
events.push(
|
||||
"quarantine",
|
||||
"critical",
|
||||
format!(
|
||||
"real-time: quarantined {} ({}) as {:?}",
|
||||
entry.original_path, virus, entry.id
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
events.push(
|
||||
"realtime",
|
||||
"warn",
|
||||
format!("real-time: failed to quarantine {path:?}: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
events.push(
|
||||
"threat",
|
||||
"critical",
|
||||
format!("real-time: {} found {} (alert-only)", virus, path.display()),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
active.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Walk `root` and add an inotify watch to it and every subdirectory,
|
||||
/// recording each watch descriptor for later path resolution.
|
||||
fn add_recursive_watches(
|
||||
inotify: &mut Inotify,
|
||||
root: &Path,
|
||||
mask: WatchMask,
|
||||
wd_map: &mut HashMap<WatchDescriptor, PathBuf>,
|
||||
) {
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
if let Ok(wd) = inotify.watches().add(&dir, mask) {
|
||||
wd_map.insert(wd, dir.clone());
|
||||
}
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what an inotify event means for us.
|
||||
/// Returns (is_file_write_event, is_new_dir, resolved_path).
|
||||
fn classify<S: AsRef<std::ffi::OsStr>>(
|
||||
ev: &inotify::Event<S>,
|
||||
wd_map: &HashMap<WatchDescriptor, PathBuf>,
|
||||
) -> Option<(bool, bool, PathBuf)> {
|
||||
let base = wd_map.get(&ev.wd)?.clone();
|
||||
|
||||
let mut path = base.clone();
|
||||
if let Some(name) = &ev.name {
|
||||
let name = name.as_ref().to_string_lossy();
|
||||
if !name.is_empty() {
|
||||
path = path.join(name.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
let write = ev
|
||||
.mask
|
||||
.intersects(EventMask::MOVED_TO | EventMask::CREATE | EventMask::CLOSE_WRITE);
|
||||
|
||||
// A child path that resolves to a directory is a new dir to watch.
|
||||
let is_new_dir = path != base && path.is_dir();
|
||||
Some((write, is_new_dir, path))
|
||||
}
|
||||
|
||||
/// Expand a possibly-tilde path to a real directory, or None if it doesn't
|
||||
/// resolve to an existing dir.
|
||||
pub fn resolve_dir(spec: &str) -> Option<PathBuf> {
|
||||
let spec = spec.trim();
|
||||
if spec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = if let Some(rest) = spec.strip_prefix("~/") {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
PathBuf::from(home).join(rest)
|
||||
} else {
|
||||
PathBuf::from(spec)
|
||||
};
|
||||
let canonical = path.canonicalize().ok()?;
|
||||
if canonical.is_dir() {
|
||||
Some(canonical)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `path` falls under any exclude entry (prefix match).
|
||||
pub fn excluded(path: &Path, excludes: &[String]) -> bool {
|
||||
let s = path.to_string_lossy();
|
||||
excludes.iter().any(|e| {
|
||||
let e = e.trim();
|
||||
if e.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if e.ends_with('/') {
|
||||
s.starts_with(e)
|
||||
} else {
|
||||
s == e || s.starts_with(&format!("{e}/"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience for the RPC layer: quarantine one file and log it.
|
||||
pub fn quarantine_and_log(
|
||||
quarantine: &Quarantine,
|
||||
events: &EventLog,
|
||||
path: &str,
|
||||
virus: &str,
|
||||
) -> anyhow::Result<QuarantineEntry> {
|
||||
let entry = quarantine.add(path, virus)?;
|
||||
events.push(
|
||||
"quarantine",
|
||||
"critical",
|
||||
format!(
|
||||
"quarantined {} ({}) as {:?}",
|
||||
entry.original_path, virus, entry.id
|
||||
),
|
||||
);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Re-exported so callers don't reach into `settings` directly for the type.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn settings_type() -> Settings {
|
||||
Settings::default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_dir_expands_tilde() {
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
let p = resolve_dir("~/").unwrap();
|
||||
assert!(p.starts_with(PathBuf::from(&home)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dir_missing_is_none() {
|
||||
assert!(resolve_dir("/no/such/dir/here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_prefix_match() {
|
||||
let p = PathBuf::from("/proc/self/1");
|
||||
assert!(excluded(&p, &["/proc".into()]));
|
||||
assert!(excluded(&p, &["/proc/".into()]));
|
||||
assert!(!excluded(&p, &["/home".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_start_stop_is_clean() {
|
||||
let dir = std::env::temp_dir().join(format!("hound-rt-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
// Isolate settings + quarantine data.
|
||||
let cfg = std::env::temp_dir().join(format!("hound-rt-cfg-{}", std::process::id()));
|
||||
let data = std::env::temp_dir().join(format!("hound-rt-data-{}", std::process::id()));
|
||||
let _env_guard = crate::test_util::locked();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &cfg);
|
||||
std::env::set_var("XDG_DATA_HOME", &data);
|
||||
|
||||
let settings = SettingsStore::load();
|
||||
let events = EventLog::new();
|
||||
let quarantine = Quarantine::new();
|
||||
let mon = RealtimeMonitor::new(settings, quarantine, events);
|
||||
mon.start().unwrap();
|
||||
assert!(mon.is_running());
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let st = mon.status();
|
||||
assert!(st.uptime_secs < 5);
|
||||
mon.stop();
|
||||
assert!(!mon.is_running());
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
for d in [&dir, &cfg, &data] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
crates/houndd/src/rootkit.rs
Normal file
335
crates/houndd/src/rootkit.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//! Rootkit detection.
|
||||
//!
|
||||
//! Without a kernel module we can't do full DKM/rootkit detection, but we
|
||||
//! can run a set of userspace heuristics that catch the *common* rootkit
|
||||
//! tricks. Each check is a "finding" with a severity; the aggregate scan
|
||||
//! reports every finding plus a pass/fail verdict.
|
||||
//!
|
||||
//! Checks performed (each individually testable):
|
||||
//!
|
||||
//! 1. **Hidden processes** — every `pid` in `/proc` must be readable.
|
||||
//! Rootkits that `hide` a process by making `/proc/<pid>` unreadable
|
||||
//! (or via a `hidepid` mount) surface here as a "unreadable pid".
|
||||
//! 2. **Hidden files** — every entry reported by a raw `readdir` of a
|
||||
//! watched dir must be visible to `fs::read_dir`'s metadata probe.
|
||||
//! A file that exists in the dir listing but whose `stat` fails is a
|
||||
//! strong signal (classic `lsof`-vs-`ls` discrepancy).
|
||||
//! 3. **Writable system dirs** — `/etc`, `/bin`, `/lib`, `/lib64`,
|
||||
//! `/sbin`, `/usr/bin`, `/usr/lib`, `/usr/lib64`, `/boot` should be
|
||||
//! write-protected for non-root. A writable system dir is where
|
||||
//! rootkits drop modified binaries.
|
||||
//! 4. **Setuid/setgid anomalies** — collect setuid binaries under
|
||||
//! `/usr` and flag ones we don't expect (a plain list, easy to extend).
|
||||
//!
|
||||
//! These run without root for the common case; root gives stronger
|
||||
//! signals (e.g. the hidden-pid check is only meaningful when we can
|
||||
//! actually read `/proc`).
|
||||
|
||||
use hound_api::{RootkitFinding, RootkitScan};
|
||||
use std::path::Path;
|
||||
|
||||
/// The full rootkit scan. `watch_dirs` are the dirs to run the
|
||||
/// hidden-file check against (typically the same set the real-time
|
||||
/// monitor watches, so a dropped rootkit file gets caught on both
|
||||
/// paths).
|
||||
pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
|
||||
let mut findings: Vec<RootkitFinding> = Vec::new();
|
||||
findings.extend(hidden_pids());
|
||||
findings.extend(hidden_files(watch_dirs));
|
||||
findings.extend(writable_system_dirs());
|
||||
findings.extend(setuid_anomalies());
|
||||
|
||||
let critical = findings.iter().filter(|f| f.severity == "critical").count() as u32;
|
||||
let warn = findings.iter().filter(|f| f.severity == "warn").count() as u32;
|
||||
let info = findings.iter().filter(|f| f.severity == "info").count() as u32;
|
||||
|
||||
let clean = critical == 0 && warn == 0;
|
||||
let verdict = if clean {
|
||||
"clean".into()
|
||||
} else {
|
||||
format!("{critical} critical, {warn} warning(s)")
|
||||
};
|
||||
|
||||
RootkitScan {
|
||||
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
|
||||
verdict,
|
||||
critical,
|
||||
warn,
|
||||
info,
|
||||
findings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check 1: any pid in /proc that is unreadable.
|
||||
fn hidden_pids() -> Vec<RootkitFinding> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/proc") else {
|
||||
out.push(finding(
|
||||
"info",
|
||||
"proc",
|
||||
"/proc not readable — running without enough privilege for a hidden-pid check"
|
||||
.to_string(),
|
||||
));
|
||||
return out;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name();
|
||||
let s = match name.to_str() {
|
||||
Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let p = Path::new("/proc").join(&s);
|
||||
// A rootkit hiding a process makes /proc/<pid> unreadable.
|
||||
if let Ok(md) = e.metadata() {
|
||||
if !md.is_dir() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Read the first byte of /proc/<pid>/comm — a hidden process
|
||||
// often makes this fail with EACCES/EAGAIN.
|
||||
let comm = p.join("comm");
|
||||
if std::fs::read(&comm).is_err() {
|
||||
out.push(finding(
|
||||
"warn",
|
||||
"hidden_pid",
|
||||
format!("/proc/{s} exists but is unreadable — process may be hidden"),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check 2: files in a watched dir that readdir sees but stat can't resolve.
|
||||
fn hidden_files(watch_dirs: &[String]) -> Vec<RootkitFinding> {
|
||||
let mut out = Vec::new();
|
||||
for d in watch_dirs {
|
||||
let path = Path::new(d);
|
||||
let Ok(rd) = std::fs::read_dir(path) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
// If readdir gave us the entry but stat-by-path fails, that's
|
||||
// a discrepancy. We re-stat by full path (not the DirEntry's
|
||||
// already-cached metadata) to detect this class of rootkit.
|
||||
if std::fs::metadata(&p).is_err() {
|
||||
out.push(finding(
|
||||
"warn",
|
||||
"hidden_file",
|
||||
format!(
|
||||
"{} visible in dir listing but stat-by-path fails",
|
||||
p.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check 3: writable system dirs.
|
||||
fn writable_system_dirs() -> Vec<RootkitFinding> {
|
||||
const SYSTEM_DIRS: &[&str] = &[
|
||||
"/etc",
|
||||
"/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/sbin",
|
||||
"/usr/bin",
|
||||
"/usr/lib",
|
||||
"/usr/lib64",
|
||||
"/boot",
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
// Determine our effective uid so we know whether "writable" is
|
||||
// actually a concern (if we're root, everything is writable).
|
||||
let uid = current_uid();
|
||||
let root = uid == 0;
|
||||
for d in SYSTEM_DIRS {
|
||||
let p = Path::new(d);
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// A dir is writable by *someone other than us* if:
|
||||
// - group/other write bits are set, OR
|
||||
// - the owning group/other is writable and we're not the owner.
|
||||
// We approximate with the simple heuristic: group or other write
|
||||
// bit set. This is intentionally conservative — it flags real
|
||||
// problems and accepts a few false positives on permissive setups.
|
||||
let Ok(md) = std::fs::metadata(p) else {
|
||||
continue;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let mode = md.mode();
|
||||
let group_w = mode & 0o020 != 0;
|
||||
let other_w = mode & 0o002 != 0;
|
||||
if (group_w || other_w) && !root {
|
||||
out.push(finding(
|
||||
"warn",
|
||||
"writable_system_dir",
|
||||
format!("{d} is group/other writable (mode {mode:o})"),
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (uid, md);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check 4: setuid/setgid binaries under /usr that look unusual.
|
||||
fn setuid_anomalies() -> Vec<RootkitFinding> {
|
||||
let mut out = Vec::new();
|
||||
let expected = [
|
||||
"sudo",
|
||||
"sudoedit",
|
||||
"su",
|
||||
"sg",
|
||||
"newgrp",
|
||||
"pkexec",
|
||||
"doas",
|
||||
"at",
|
||||
"crontab",
|
||||
"chfn",
|
||||
"chsh",
|
||||
"chage",
|
||||
"chgpasswd",
|
||||
"passwd",
|
||||
"gpasswd",
|
||||
"expiry",
|
||||
"unix_chkpwd",
|
||||
"pam_extrausers_chkpwd",
|
||||
"pam_timestamp_check",
|
||||
"mount",
|
||||
"umount",
|
||||
"mount.cifs",
|
||||
"mount.smb3",
|
||||
"mount.nfs",
|
||||
"mount.nfs4",
|
||||
"mount.ecryptfs_private",
|
||||
"umount.ecryptfs_private",
|
||||
"fusermount",
|
||||
"fusermount3",
|
||||
"newuidmap",
|
||||
"newgidmap",
|
||||
"ping",
|
||||
"ping6",
|
||||
"ip",
|
||||
"ip6",
|
||||
"Xorg",
|
||||
"ssh-agent",
|
||||
"gpg-agent",
|
||||
"dbus-daemon-launch-helper",
|
||||
"polkit-agent-helper-1",
|
||||
"dotlockfile",
|
||||
"locate",
|
||||
"plocate",
|
||||
"pppd",
|
||||
"postdrop",
|
||||
"postqueue",
|
||||
"newsyslog",
|
||||
"mullvad-exclude",
|
||||
"screen",
|
||||
"tmux",
|
||||
];
|
||||
let roots = ["/usr/bin", "/usr/sbin", "/usr/local/bin"];
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for r in roots {
|
||||
let Ok(rd) = std::fs::read_dir(r) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = std::fs::metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let mode = md.mode();
|
||||
let setuid = mode & 0o4000 != 0;
|
||||
let setgid = mode & 0o2000 != 0;
|
||||
if !setuid && !setgid {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||
let Some(name) = name else { continue };
|
||||
if seen.insert(name.clone()) && !expected.iter().any(|x| *x == name) {
|
||||
out.push(finding(
|
||||
"info",
|
||||
"setuid_anomaly",
|
||||
format!("unexpected setuid/setgid binary: {}", p.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = md;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
|
||||
RootkitFinding {
|
||||
check: kind.to_string(),
|
||||
severity: sev.to_string(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_uid() -> u32 {
|
||||
// /proc/self is the portable, no-dependency way on Linux.
|
||||
if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
|
||||
for line in s.lines() {
|
||||
if let Some(v) = line.strip_prefix("Uid:") {
|
||||
if let Some(first) = v.split_whitespace().next() {
|
||||
if let Ok(uid) = first.parse() {
|
||||
return uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
u32::MAX
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scan_shape_is_consistent() {
|
||||
let scan = run_scan(&[]);
|
||||
let total = scan.findings.len() as u32;
|
||||
assert_eq!(scan.critical + scan.warn + scan.info, total);
|
||||
assert!(!scan.verdict.is_empty());
|
||||
assert!(scan.ts.len() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_file_detects_unstatable() {
|
||||
// Create a real dir and a real file — should produce no finding.
|
||||
let dir = std::env::temp_dir().join(format!("hound-rk-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let f = dir.join("ok.txt");
|
||||
std::fs::write(&f, b"hi").unwrap();
|
||||
let out = hidden_files(&[dir.to_string_lossy().to_string()]);
|
||||
assert!(out.is_empty(), "unexpected finding: {out:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_uid_is_sane() {
|
||||
let uid = current_uid();
|
||||
assert!(uid < 10_000);
|
||||
}
|
||||
}
|
||||
151
crates/houndd/src/settings.rs
Normal file
151
crates/houndd/src/settings.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
//! Settings persistence.
|
||||
//!
|
||||
//! Stored as JSON at `$XDG_CONFIG_HOME/hound/settings.json` (falling back
|
||||
//! to `~/.config/hound/`). The daemon holds a live copy in
|
||||
//! [`crate::DaemonState`]; every mutation here is also written to disk so
|
||||
//! the settings survive a daemon restart.
|
||||
//!
|
||||
//! Load is tolerant: a missing or corrupt file yields `Settings::default()`
|
||||
//! rather than crashing the daemon over a settings knob.
|
||||
|
||||
use hound_api::Settings;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Where the settings file lives for this user.
|
||||
pub fn settings_path() -> PathBuf {
|
||||
let cfg = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let base = match cfg {
|
||||
Some(c) => PathBuf::from(c),
|
||||
None => {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
PathBuf::from(home).join(".config")
|
||||
}
|
||||
};
|
||||
base.join("hound").join("settings.json")
|
||||
}
|
||||
|
||||
/// A live, persisted settings store. `Arc` so it can be shared between the
|
||||
/// RPC threads and the realtime monitor without copying the struct.
|
||||
pub struct SettingsStore {
|
||||
inner: Arc<Mutex<Settings>>,
|
||||
}
|
||||
|
||||
impl Clone for SettingsStore {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Load from disk, or start from defaults and persist them so the file
|
||||
/// exists for the user to inspect/edit.
|
||||
pub fn load() -> Self {
|
||||
let path = settings_path();
|
||||
let loaded = std::fs::read_to_string(&path).ok().and_then(|c| {
|
||||
match serde_json::from_str::<Settings>(&c) {
|
||||
Ok(s) => Some(s),
|
||||
Err(_) => {
|
||||
eprintln!("settings: corrupt {}, using defaults", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let settings = loaded.unwrap_or_else(|| {
|
||||
// New install: persist defaults so the file is visible/editable.
|
||||
let d = Settings::default();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&path, serde_json::to_string_pretty(&d).unwrap());
|
||||
d
|
||||
});
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(settings)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone of the current settings (for reading in handlers).
|
||||
pub fn get(&self) -> Settings {
|
||||
self.inner.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Replace the live settings and persist to disk.
|
||||
pub fn set(&self, next: &Settings) -> Result<(), String> {
|
||||
let path = settings_path();
|
||||
let json = serde_json::to_string_pretty(next).map_err(|e| e.to_string())?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())?;
|
||||
*self.inner.lock().unwrap() = next.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap in a specific field by closure, persisting the result. Used by
|
||||
/// `realtime.set_enabled` and `settings.set` without a read-modify race.
|
||||
pub fn mutate(&self, f: impl FnOnce(&mut Settings)) -> Settings {
|
||||
let mut guard = self.inner.lock().unwrap();
|
||||
f(&mut guard);
|
||||
let snapshot = guard.clone();
|
||||
if let Some(parent) = settings_path().parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(
|
||||
settings_path(),
|
||||
serde_json::to_string_pretty(&snapshot).unwrap(),
|
||||
);
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_have_sane_values() {
|
||||
let s = Settings::default();
|
||||
assert!(s.realtime_enabled);
|
||||
assert!(s.ransomware_guard);
|
||||
assert!(!s.paused);
|
||||
assert!(!s.exclude_paths.is_empty());
|
||||
assert_eq!(s.on_detect, "quarantine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_through_json() {
|
||||
let s = Settings::default();
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
let back: Settings = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(
|
||||
back.ransomware_threshold_per_min,
|
||||
s.ransomware_threshold_per_min
|
||||
);
|
||||
assert_eq!(back.realtime_watch, s.realtime_watch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_set_get() {
|
||||
// Isolate the config dir for this test.
|
||||
let dir = std::env::temp_dir().join(format!("hound-settings-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _env_guard = crate::test_util::locked();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
let store = SettingsStore::load();
|
||||
let mut s = store.get();
|
||||
s.ransomware_threshold_per_min = 7;
|
||||
s.on_detect = "alert".into();
|
||||
store.set(&s).unwrap();
|
||||
let reloaded = SettingsStore::load();
|
||||
assert_eq!(reloaded.get().ransomware_threshold_per_min, 7);
|
||||
assert_eq!(reloaded.get().on_detect, "alert");
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
22
crates/houndd/src/test_util.rs
Normal file
22
crates/houndd/src/test_util.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Shared test utilities.
|
||||
//!
|
||||
//! One crate-wide lock for tests that mutate process environment variables
|
||||
//! (XDG_*, HOUNDD_*). The daemon resolves its config/data paths from the
|
||||
//! environment at call time, so two tests running on different `cargo test`
|
||||
//! threads and setting the same variable will clobber each other's store
|
||||
//! locations. Tests that touch env vars must hold this lock for the whole
|
||||
//! test body.
|
||||
|
||||
/// Crate-wide mutex serializing process-environment mutations in tests.
|
||||
pub fn env_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
/// Lock, tolerating a poisoned mutex (a previous test panicked while
|
||||
/// holding it) so one failure doesn't cascade into the rest of the suite.
|
||||
pub fn locked() -> std::sync::MutexGuard<'static, ()> {
|
||||
env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
514
gui/dist/app.js
vendored
Normal file
514
gui/dist/app.js
vendored
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
// Hound Antivirus — webview front-end.
|
||||
// Thin view over houndd via Tauri commands; 6-tab layout.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const ICONS = {
|
||||
protected: "state-protected-48.png",
|
||||
scanning: "state-scanning-48.png",
|
||||
threat: "state-threat-48.png",
|
||||
paused: "state-paused-48.png",
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
protected: "Protected",
|
||||
scanning: "Scanning…",
|
||||
threat: "Threat Detected",
|
||||
paused: "Paused",
|
||||
};
|
||||
|
||||
const SUBS = {
|
||||
protected: "Your system looks healthy.",
|
||||
scanning: "Hound is working the queue.",
|
||||
threat: "We found something that should not be there.",
|
||||
paused: "Real-time protection is off.",
|
||||
};
|
||||
|
||||
let currentState = "protected";
|
||||
let busy = false;
|
||||
let paused = false;
|
||||
|
||||
// ── Tabs ───────────────────────────────────────────────────────────
|
||||
const LOADERS = {
|
||||
quarantine: loadQuarantine,
|
||||
realtime: loadRealtime,
|
||||
rootkit: null, // on demand
|
||||
alerts: loadAlerts,
|
||||
settings: loadSettings,
|
||||
};
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((t) =>
|
||||
t.classList.toggle("active", t.dataset.tab === name));
|
||||
document.querySelectorAll(".tab-panel").forEach((p) =>
|
||||
p.classList.toggle("active", p.id === "panel-" + name));
|
||||
const fn = LOADERS[name];
|
||||
if (fn) fn().catch(() => {});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach((t) =>
|
||||
t.addEventListener("click", () => switchTab(t.dataset.tab)));
|
||||
|
||||
// ── State rendering (hero + tray) ─────────────────────────────────
|
||||
function setState(state) {
|
||||
if (!ICONS[state]) state = "protected";
|
||||
currentState = state;
|
||||
$("hero").dataset.state = state;
|
||||
$("shield-icon").src = ICONS[state];
|
||||
$("hero-title").textContent = TITLES[state];
|
||||
$("hero-sub").textContent = SUBS[state];
|
||||
invoke("set_state", { state }).catch(() => {});
|
||||
}
|
||||
|
||||
function setEngineDot(cls, label) {
|
||||
$("engine-dot").className = "engine-dot " + cls;
|
||||
$("engine-label").textContent = label;
|
||||
}
|
||||
|
||||
function fmtDbAge(iso) {
|
||||
const t = new Date(iso);
|
||||
if (Number.isNaN(t.getTime())) return iso;
|
||||
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "1 day ago";
|
||||
if (days < 30) return `${days} days ago`;
|
||||
const mo = Math.floor(days / 30);
|
||||
return `${mo} month${mo > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
function renderStatus(st) {
|
||||
setEngineDot(st.engine_present ? "ok" : "bad",
|
||||
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
|
||||
$("pill-os").textContent = "OS: " + (st.os || "—");
|
||||
$("pill-engine").textContent = "engine: " + (st.engine || "—");
|
||||
const pill = $("pill-db");
|
||||
if (st.db) {
|
||||
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
|
||||
const staleDays = (Date.now() - new Date(st.db.updated_at).getTime()) / 86400000;
|
||||
pill.classList.toggle("stale", staleDays > 3);
|
||||
$("foot-ver").textContent = `houndd ${st.daemon_version} · ${st.db.file}`;
|
||||
} else {
|
||||
pill.textContent = "signatures: none";
|
||||
pill.classList.add("stale");
|
||||
$("foot-ver").textContent = `houndd ${st.daemon_version}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scans ──────────────────────────────────────────────────────────
|
||||
async function doScan(path) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
setButtons(true);
|
||||
$("scan-panel").classList.remove("hidden");
|
||||
$("scan-panel").querySelector(".progress").classList.add("indeterminate");
|
||||
$("scan-title").textContent = "Scanning…";
|
||||
$("scan-target").textContent = path;
|
||||
setState("scanning");
|
||||
try {
|
||||
const r = await invoke("scan", { path, recursive: true });
|
||||
renderScanResult(r, path);
|
||||
} catch (e) {
|
||||
$("scan-note").textContent = "Scan failed: " + String(e);
|
||||
setState(paused ? "paused" : "protected");
|
||||
} finally {
|
||||
busy = false;
|
||||
setButtons(false);
|
||||
$("scan-panel").querySelector(".progress").classList.remove("indeterminate");
|
||||
}
|
||||
}
|
||||
|
||||
function renderScanResult(r, path) {
|
||||
const clean = r.infected === 0;
|
||||
setState(clean ? (paused ? "paused" : "protected") : "threat");
|
||||
$("scan-title").textContent = clean
|
||||
? "Scan complete — clean"
|
||||
: `Scan complete — ${r.infected} threat${r.infected > 1 ? "s" : ""}`;
|
||||
$("scan-note").textContent =
|
||||
`${r.scanned.toLocaleString()} files scanned · ${r.clean.toLocaleString()} clean · ${r.infected.toLocaleString()} infected`;
|
||||
$("progress-bar").style.width = "100%";
|
||||
|
||||
$("results-meta").textContent = `${r.scanned} files · ${path}`;
|
||||
const body = $("results-body");
|
||||
body.innerHTML = "";
|
||||
if (clean) {
|
||||
body.innerHTML =
|
||||
`<div class="result-row clean">
|
||||
<span class="sig">✔ All clear</span>
|
||||
<span class="path">${r.scanned.toLocaleString()} files scanned, nothing flagged</span>
|
||||
<span></span>
|
||||
</div>`;
|
||||
} else {
|
||||
for (const f of r.found.slice(0, 50)) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "result-row infected";
|
||||
div.innerHTML =
|
||||
`<span class="sig">✘ ${escapeHtml(f.virus)}</span>
|
||||
<span class="path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</span>
|
||||
<span></span>`;
|
||||
body.appendChild(div);
|
||||
}
|
||||
if (r.found.length > 50) {
|
||||
const more = document.createElement("div");
|
||||
more.className = "muted";
|
||||
more.style.padding = "4px 2px";
|
||||
more.textContent = `…and ${r.found.length - 50} more`;
|
||||
body.appendChild(more);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signature update ───────────────────────────────────────────────
|
||||
async function doUpdate() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
setButtons(true);
|
||||
$("update-panel").classList.remove("hidden");
|
||||
const log = $("update-log");
|
||||
log.className = "log";
|
||||
log.textContent = "Running freshclam — this can take a minute…\n";
|
||||
try {
|
||||
const u = await invoke("update");
|
||||
log.textContent = u.output.trim();
|
||||
log.className = "log " + (u.ok ? "ok" : "fail");
|
||||
if (u.status) renderStatus(u.status);
|
||||
setState(paused ? "paused" : "protected");
|
||||
} catch (e) {
|
||||
log.textContent = "Update failed: " + String(e);
|
||||
log.className = "log fail";
|
||||
} finally {
|
||||
busy = false;
|
||||
setButtons(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quarantine tab ─────────────────────────────────────────────────
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = bytes, i = -1;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return v.toFixed(v < 10 ? 1 : 0) + " " + units[i];
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
const t = new Date(iso);
|
||||
if (Number.isNaN(t.getTime())) return iso;
|
||||
return t.toLocaleString();
|
||||
}
|
||||
|
||||
function setQuarantineBadge(n) {
|
||||
const b = $("badge-quarantine");
|
||||
b.classList.toggle("hidden", n === 0);
|
||||
b.textContent = n;
|
||||
}
|
||||
|
||||
async function loadQuarantine() {
|
||||
try {
|
||||
const list = await invoke("quarantine_list");
|
||||
const body = $("qt-body");
|
||||
body.innerHTML = "";
|
||||
setQuarantineBadge(list.length);
|
||||
$("qt-count").textContent = list.length
|
||||
? `${list.length} item${list.length > 1 ? "s" : ""} held`
|
||||
: "vault is empty";
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<p class="muted empty">Nothing in quarantine. Your clean system thanks you.</p>';
|
||||
return;
|
||||
}
|
||||
for (const e of list) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "qt-row" + (e.restored ? " restored" : "");
|
||||
row.innerHTML =
|
||||
`<span class="qt-virus">${escapeHtml(e.virus)}</span>
|
||||
<span>
|
||||
<span class="qt-path" title="${escapeHtml(e.original_path)}">${escapeHtml(e.original_path)}</span><br/>
|
||||
<span class="qt-meta">${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""}</span>
|
||||
</span>
|
||||
<span class="qt-actions"></span>`;
|
||||
const actions = row.querySelector(".qt-actions");
|
||||
if (!e.restored) {
|
||||
const rb = document.createElement("button");
|
||||
rb.className = "btn small";
|
||||
rb.textContent = "Restore";
|
||||
rb.onclick = async () => {
|
||||
rb.disabled = true;
|
||||
try { await invoke("quarantine_restore", { id: e.id }); loadQuarantine(); }
|
||||
catch (err) { rb.disabled = false; alert("Restore failed: " + err); }
|
||||
};
|
||||
actions.appendChild(rb);
|
||||
}
|
||||
const xb = document.createElement("button");
|
||||
xb.className = "btn small danger";
|
||||
xb.textContent = "Remove";
|
||||
xb.onclick = async () => {
|
||||
if (!confirm(`Remove ${e.virus} from the vault? The bytes are deleted for good.`)) return;
|
||||
xb.disabled = true;
|
||||
try { await invoke("quarantine_remove", { id: e.id }); loadQuarantine(); }
|
||||
catch (err) { xb.disabled = false; alert("Remove failed: " + err); }
|
||||
};
|
||||
actions.appendChild(xb);
|
||||
body.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Realtime tab ───────────────────────────────────────────────────
|
||||
function fmtUptime(secs) {
|
||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
async function loadRealtime() {
|
||||
try {
|
||||
const rt = await invoke("realtime_status");
|
||||
const st = await invoke("settings");
|
||||
$("rt-toggle").checked = rt.enabled;
|
||||
$("rt-enabled-label").textContent = rt.enabled ? "monitor running" : "monitor off";
|
||||
$("rt-seen").textContent = rt.files_seen.toLocaleString();
|
||||
$("rt-quar").textContent = rt.files_quarantined.toLocaleString();
|
||||
$("rt-uptime").textContent = rt.active ? fmtUptime(rt.uptime_secs) : "—";
|
||||
const rw = $("rt-ransom");
|
||||
rw.textContent = rt.ransomware;
|
||||
rw.className = "stat-val " +
|
||||
(rt.ransomware === "alarm" ? "alarm" : rt.ransomware === "watching" ? "watching" : "calm");
|
||||
$("rt-watch").textContent = rt.watching.join(", ") || "—";
|
||||
$("rt-last").textContent = rt.last_event_at ? fmtTime(rt.last_event_at) : "none yet";
|
||||
$("rt-action").textContent = st.on_detect;
|
||||
const wl = $("rt-watch-list");
|
||||
wl.innerHTML = "";
|
||||
for (const dir of st.realtime_watch) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "watch-chip";
|
||||
chip.textContent = dir;
|
||||
wl.appendChild(chip);
|
||||
}
|
||||
} catch (e) {
|
||||
$("rt-enabled-label").textContent = "error: " + String(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rootkit tab ────────────────────────────────────────────────────
|
||||
async function runRootkit() {
|
||||
const btn = $("btn-rootkit");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Scanning…";
|
||||
$("rootkit-banner").classList.add("hidden");
|
||||
$("rootkit-body").innerHTML = '<p class="muted empty">Checking setuid bits, deleted executables, world-writable binaries…</p>';
|
||||
try {
|
||||
const r = await invoke("rootkit_scan");
|
||||
const banner = $("rootkit-banner");
|
||||
const dirty = r.critical + r.warn > 0;
|
||||
banner.textContent = dirty ? `⚠ ${r.verdict}` : `✔ ${r.verdict} — ${r.info} informational note${r.info === 1 ? "" : "s"}`;
|
||||
banner.className = "verdict " + (dirty ? "dirty" : "clean");
|
||||
banner.classList.remove("hidden");
|
||||
|
||||
const body = $("rootkit-body");
|
||||
body.innerHTML = "";
|
||||
if (!r.findings.length) {
|
||||
body.innerHTML = '<p class="muted empty">No findings at all — squeaky clean.</p>';
|
||||
return;
|
||||
}
|
||||
const rank = { critical: 0, warn: 1, info: 2 };
|
||||
const sorted = [...r.findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3));
|
||||
for (const f of sorted) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "finding-row";
|
||||
row.innerHTML =
|
||||
`<span class="sev ${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span>
|
||||
<span class="check">${escapeHtml(f.check)}</span>
|
||||
<span class="detail">${escapeHtml(f.detail)}</span>`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(String(e))}</p>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Run Scan";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alerts tab ─────────────────────────────────────────────────────
|
||||
function setAlertsBadge(n) {
|
||||
const b = $("badge-alerts");
|
||||
b.classList.toggle("hidden", n === 0);
|
||||
b.textContent = n;
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const list = await invoke("events", { limit: 200 });
|
||||
$("alerts-count").textContent = list.length ? `${list.length} events` : "";
|
||||
setAlertsBadge(list.filter((e) => e.severity === "critical").length);
|
||||
const body = $("alerts-body");
|
||||
body.innerHTML = "";
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<p class="muted empty">No events yet — the dog hasn’t barked.</p>';
|
||||
return;
|
||||
}
|
||||
for (const e of list) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "event-row";
|
||||
row.innerHTML =
|
||||
`<span class="ev-sev ${escapeHtml(e.severity)}">${escapeHtml(e.severity)}</span>
|
||||
<span class="ev-kind">${escapeHtml(e.kind)}</span>
|
||||
<span class="ev-msg">${escapeHtml(e.message)}</span>
|
||||
<span class="ev-ts">${fmtTime(e.ts)}</span>`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings tab ───────────────────────────────────────────────────
|
||||
let settingsCache = null;
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const s = await invoke("settings");
|
||||
settingsCache = s;
|
||||
$("set-paused").checked = s.paused;
|
||||
$("set-autoupdate").checked = s.auto_update_signatures;
|
||||
$("set-notify").checked = s.notify_desktop;
|
||||
$("set-realtime").checked = s.realtime_enabled;
|
||||
$("set-watch").value = (s.realtime_watch || []).join("\n");
|
||||
$("set-ondetect").value = s.on_detect;
|
||||
$("set-maxsize").value = s.max_file_size_mb;
|
||||
$("set-excludes").value = (s.exclude_paths || []).join("\n");
|
||||
$("set-recursive").checked = s.recursive_default;
|
||||
$("set-ransom").checked = s.ransomware_guard;
|
||||
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
|
||||
$("set-rootkit").checked = s.rootkit_enabled;
|
||||
} catch (e) {
|
||||
$("settings-msg").textContent = "Failed to load: " + String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (!settingsCache) return;
|
||||
const s = settingsCache;
|
||||
s.paused = $("set-paused").checked;
|
||||
s.auto_update_signatures = $("set-autoupdate").checked;
|
||||
s.notify_desktop = $("set-notify").checked;
|
||||
s.realtime_enabled = $("set-realtime").checked;
|
||||
s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean);
|
||||
s.on_detect = $("set-ondetect").value;
|
||||
s.max_file_size_mb = Math.max(1, parseInt($("set-maxsize").value, 10) || 0);
|
||||
s.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean);
|
||||
s.recursive_default = $("set-recursive").checked;
|
||||
s.ransomware_guard = $("set-ransom").checked;
|
||||
s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100);
|
||||
s.rootkit_enabled = $("set-rootkit").checked;
|
||||
try {
|
||||
const saved = await invoke("set_settings", { s });
|
||||
paused = saved.paused;
|
||||
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
|
||||
$("settings-msg").textContent = "Saved ✓";
|
||||
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
|
||||
loadRealtime().catch(() => {});
|
||||
} catch (e) {
|
||||
$("settings-msg").textContent = "Save failed: " + String(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ─────────────────────────────────────────────────────────
|
||||
function setButtons(disabled) {
|
||||
for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"])
|
||||
$(id).disabled = disabled;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>\"]/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const dir = await open({ directory: true, multiple: false, title: "Choose a folder to scan" });
|
||||
if (dir) doScan(dir);
|
||||
} catch {
|
||||
const p = prompt("Folder to scan:");
|
||||
if (p) doScan(p);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
|
||||
setState(paused ? "paused" : "protected");
|
||||
}
|
||||
|
||||
$("btn-scan-home").addEventListener("click", () => doScan("~"));
|
||||
$("btn-scan-custom").addEventListener("click", pickFolder);
|
||||
$("btn-update").addEventListener("click", doUpdate);
|
||||
$("btn-pause").addEventListener("click", togglePause);
|
||||
$("btn-qt-refresh").addEventListener("click", () => loadQuarantine());
|
||||
$("btn-alerts-refresh").addEventListener("click", () => loadAlerts());
|
||||
$("btn-rootkit").addEventListener("click", runRootkit);
|
||||
$("btn-settings-save").addEventListener("click", saveSettings);
|
||||
|
||||
$("btn-qt-add").addEventListener("click", async () => {
|
||||
const path = $("qt-add-path").value.trim();
|
||||
const virus = $("qt-add-virus").value.trim() || "manual";
|
||||
if (!path) return alert("Enter a file path to quarantine.");
|
||||
try {
|
||||
await invoke("quarantine_add", { path, virus });
|
||||
$("qt-add-path").value = "";
|
||||
loadQuarantine();
|
||||
} catch (e) { alert("Quarantine failed: " + e); }
|
||||
});
|
||||
|
||||
$("btn-alerts-clear").addEventListener("click", async () => {
|
||||
if (!confirm("Clear the whole event log?")) return;
|
||||
try {
|
||||
const n = await invoke("clear_events");
|
||||
$("alerts-count").textContent = `${n} event${n === 1 ? "" : "s"} cleared`;
|
||||
loadAlerts();
|
||||
} catch (e) { alert("Clear failed: " + e); }
|
||||
});
|
||||
|
||||
$("rt-toggle").addEventListener("change", async (ev) => {
|
||||
try {
|
||||
await invoke("realtime_set_enabled", { enabled: ev.target.checked });
|
||||
loadRealtime();
|
||||
} catch (e) {
|
||||
ev.target.checked = !ev.target.checked;
|
||||
alert("Toggle failed: " + e);
|
||||
}
|
||||
});
|
||||
|
||||
// Tray menu events (Scan Home / Scan Downloads / Update).
|
||||
listen("tray-event", (e) => {
|
||||
const p = e.payload;
|
||||
if (p?.action === "scan" && p.path) {
|
||||
switchTab("protection");
|
||||
doScan(p.path);
|
||||
} else if (p?.action === "update") {
|
||||
switchTab("protection");
|
||||
doUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial boot.
|
||||
async function boot() {
|
||||
try {
|
||||
const st = await invoke("status");
|
||||
renderStatus(st);
|
||||
if (st.engine_present) setState(paused ? "paused" : "protected");
|
||||
else setState("paused");
|
||||
} catch {
|
||||
setEngineDot("bad", "engine offline");
|
||||
setState("paused");
|
||||
$("hero-sub").textContent =
|
||||
"Can't reach houndd. Is the daemon running? (try `cargo run -p houndd`)";
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
BIN
gui/dist/favicon-32.png
vendored
Normal file
BIN
gui/dist/favicon-32.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gui/dist/hound-32.png
vendored
Normal file
BIN
gui/dist/hound-32.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
285
gui/dist/index.html
vendored
Normal file
285
gui/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hound Antivirus</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon-32.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────── -->
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img src="hound-32.png" alt="" class="brand-mark" />
|
||||
<div class="brand-text">
|
||||
<h1>Hound</h1>
|
||||
<span class="tagline">Antivirus for Linux</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="engine-dot" id="engine-dot" title="Engine status"></span>
|
||||
<span class="engine-label" id="engine-label">connecting…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────────────────── -->
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="protection" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Protection
|
||||
</button>
|
||||
<button class="tab" data-tab="quarantine" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8v13H3V8"/><path d="M1 3h22v5H1z"/><path d="M10 12h4"/></svg>
|
||||
Quarantine
|
||||
<span class="tab-badge hidden" id="badge-quarantine"></span>
|
||||
</button>
|
||||
<button class="tab" data-tab="realtime" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12h4l3-9 4 18 3-9h6"/></svg>
|
||||
Realtime
|
||||
</button>
|
||||
<button class="tab" data-tab="rootkit" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
Rootkit
|
||||
</button>
|
||||
<button class="tab" data-tab="alerts" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
Alerts
|
||||
<span class="tab-badge hidden" id="badge-alerts"></span>
|
||||
</button>
|
||||
<button class="tab" data-tab="settings" role="tab">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ══ Protection ═══════════════════════════════════════════ -->
|
||||
<section class="tab-panel active" id="panel-protection">
|
||||
<section class="hero" id="hero">
|
||||
<div class="shield" id="shield">
|
||||
<img id="shield-icon" src="state-protected-48.png" alt="" width="48" height="48" />
|
||||
</div>
|
||||
<div class="hero-copy">
|
||||
<h2 id="hero-title">Protected</h2>
|
||||
<p id="hero-sub">Your system looks healthy.</p>
|
||||
<div class="hero-meta">
|
||||
<span class="pill" id="pill-db">signatures: —</span>
|
||||
<span class="pill" id="pill-os">—</span>
|
||||
<span class="pill" id="pill-engine">engine: —</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="actions">
|
||||
<button class="btn primary" id="btn-scan-home">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
Scan Home
|
||||
</button>
|
||||
<button class="btn primary" id="btn-scan-custom">Scan Folder…</button>
|
||||
<button class="btn" id="btn-update">Update Signatures</button>
|
||||
<button class="btn" id="btn-pause">Pause Protection</button>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="scan-panel">
|
||||
<div class="panel-head">
|
||||
<h3 id="scan-title">Scanning…</h3>
|
||||
<span class="scan-target" id="scan-target"></span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
<p class="muted" id="scan-note">ClamAV is working the queue. This can take a while on large folders.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="results-panel">
|
||||
<div class="panel-head">
|
||||
<h3>Last Scan Results</h3>
|
||||
<span class="muted" id="results-meta"></span>
|
||||
</div>
|
||||
<div id="results-body" class="results">
|
||||
<p class="muted empty">No scans yet. Hit <strong>Scan Home</strong> to start.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="update-panel">
|
||||
<div class="panel-head">
|
||||
<h3>Signature Update</h3>
|
||||
</div>
|
||||
<pre class="log" id="update-log"></pre>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ══ Quarantine ═══════════════════════════════════════════ -->
|
||||
<section class="tab-panel" id="panel-quarantine">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Quarantine Vault</h3>
|
||||
<div class="row-actions">
|
||||
<span class="muted" id="qt-count"></span>
|
||||
<button class="btn small" id="btn-qt-refresh">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qt-body" class="qt-list">
|
||||
<p class="muted empty">Loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Add Manually</h3></div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="qt-add-path" class="input" placeholder="/path/to/suspicious/file" />
|
||||
<input type="text" id="qt-add-virus" class="input" placeholder="label (e.g. manual-suspicious)" value="manual" />
|
||||
<button class="btn" id="btn-qt-add">Quarantine</button>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">The file is moved into the vault; its original location is left empty.</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ══ Realtime ═════════════════════════════════════════════ -->
|
||||
<section class="tab-panel" id="panel-realtime">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Realtime Monitor</h3>
|
||||
<label class="switch-row">
|
||||
<span id="rt-enabled-label" class="muted">loading…</span>
|
||||
<input type="checkbox" id="rt-toggle" />
|
||||
<span class="switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="stat-grid" id="rt-stats">
|
||||
<div class="stat"><span class="stat-val" id="rt-seen">—</span><span class="stat-key">file events seen</span></div>
|
||||
<div class="stat"><span class="stat-val" id="rt-quar">—</span><span class="stat-key">auto-quarantined</span></div>
|
||||
<div class="stat"><span class="stat-val" id="rt-uptime">—</span><span class="stat-key">monitor uptime</span></div>
|
||||
<div class="stat"><span class="stat-val" id="rt-ransom">—</span><span class="stat-key">ransomware guard</span></div>
|
||||
</div>
|
||||
<div class="kv"><span class="kv-k">watching</span><span class="kv-v" id="rt-watch">—</span></div>
|
||||
<div class="kv"><span class="kv-k">last file event</span><span class="kv-v" id="rt-last">—</span></div>
|
||||
<div class="kv"><span class="kv-k">on detection</span><span class="kv-v" id="rt-action">—</span></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Watch Folders</h3></div>
|
||||
<div id="rt-watch-list" class="watch-list"></div>
|
||||
<p class="muted" style="margin-top:8px">Edit in Settings → Realtime. Restart required for the monitor to pick up new folders.</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ══ Rootkit ══════════════════════════════════════════════ -->
|
||||
<section class="tab-panel" id="panel-rootkit">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Rootkit Hunter</h3>
|
||||
<button class="btn primary" id="btn-rootkit">Run Scan</button>
|
||||
</div>
|
||||
<div id="rootkit-banner" class="verdict hidden"></div>
|
||||
<div id="rootkit-body" class="finding-list">
|
||||
<p class="muted empty">Runs userspace heuristics: deleted-but-open executables, world-writable binaries, unexpected setuid, hidden /proc entries.</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ══ Alerts ═══════════════════════════════════════════════ -->
|
||||
<section class="tab-panel" id="panel-alerts">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Event Log</h3>
|
||||
<div class="row-actions">
|
||||
<span class="muted" id="alerts-count"></span>
|
||||
<button class="btn small" id="btn-alerts-refresh">Refresh</button>
|
||||
<button class="btn small danger" id="btn-alerts-clear">Clear Log</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-body" class="event-list">
|
||||
<p class="muted empty">Loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- ══ Settings ═════════════════════════════════════════════ -->
|
||||
<section class="tab-panel" id="panel-settings">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Protection</h3></div>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Pause all protection</strong><small>Suspends realtime and grays the tray.</small></span>
|
||||
<input type="checkbox" id="set-paused" /><span class="switch"></span>
|
||||
</label>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Auto-update signatures</strong><small>Daemon runs freshclam on a schedule.</small></span>
|
||||
<input type="checkbox" id="set-autoupdate" /><span class="switch"></span>
|
||||
</label>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Desktop notifications</strong><small>Pop an alert for critical events.</small></span>
|
||||
<input type="checkbox" id="set-notify" /><span class="switch"></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Realtime</h3></div>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Realtime monitor</strong><small>Watch folders and quarantine on the fly.</small></span>
|
||||
<input type="checkbox" id="set-realtime" /><span class="switch"></span>
|
||||
</label>
|
||||
<div class="field">
|
||||
<label for="set-watch">Watch folders (one per line)</label>
|
||||
<textarea id="set-watch" class="input mono" rows="4" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-ondetect">Action on detection</label>
|
||||
<select id="set-ondetect" class="input">
|
||||
<option value="quarantine">Quarantine automatically</option>
|
||||
<option value="alert">Alert only (leave file in place)</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Scanning</h3></div>
|
||||
<div class="field">
|
||||
<label for="set-maxsize">Max file size (MB)</label>
|
||||
<input type="number" id="set-maxsize" class="input" min="1" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-excludes">Excluded paths (one per line, exact or prefix)</label>
|
||||
<textarea id="set-excludes" class="input mono" rows="4" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Recursive by default</strong></span>
|
||||
<input type="checkbox" id="set-recursive" /><span class="switch"></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Ransomware Guard</h3></div>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Enable guard</strong><small>Write-burst heuristic trips an alarm under attack.</small></span>
|
||||
<input type="checkbox" id="set-ransom" /><span class="switch"></span>
|
||||
</label>
|
||||
<div class="field">
|
||||
<label for="set-ransom-thresh">Threshold (file-writes / minute)</label>
|
||||
<input type="number" id="set-ransom-thresh" class="input" min="10" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h3>Rootkit</h3></div>
|
||||
<label class="switch-row setting">
|
||||
<span><strong>Include in scans</strong><small>Run rootkit heuristics as part of the health check.</small></span>
|
||||
<input type="checkbox" id="set-rootkit" /><span class="switch"></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="btn-settings-save">Save Settings</button>
|
||||
<span class="muted" id="settings-msg"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<span id="foot-ver">houndd —</span>
|
||||
<span class="muted">engine: ClamAV via Unix socket</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
gui/dist/state-paused-48.png
vendored
Normal file
BIN
gui/dist/state-paused-48.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/dist/state-protected-48.png
vendored
Normal file
BIN
gui/dist/state-protected-48.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/dist/state-scanning-48.png
vendored
Normal file
BIN
gui/dist/state-scanning-48.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/dist/state-threat-48.png
vendored
Normal file
BIN
gui/dist/state-threat-48.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
446
gui/dist/styles.css
vendored
Normal file
446
gui/dist/styles.css
vendored
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
:root {
|
||||
/* surfaces */
|
||||
--bg: #0B0E14;
|
||||
--bg-raised: #12161F;
|
||||
--bg-panel: #161B26;
|
||||
--bg-hover: #1D2432;
|
||||
--border: #232B3A;
|
||||
--border-hi: #32405A;
|
||||
|
||||
/* text */
|
||||
--fg: #E8ECF4;
|
||||
--fg-dim: #8B96AB;
|
||||
--fg-faint: #5A6478;
|
||||
|
||||
/* brand + state (dog head, 4-state ladder) */
|
||||
--brand: #9896E0;
|
||||
--ok: #22C55E;
|
||||
--warn: #F59E0B;
|
||||
--bad: #EF4444;
|
||||
--off: #6B7280;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 8px 30px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: radial-gradient(1200px 700px at 20% -10%, #141B2B 0%, var(--bg) 55%);
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 "Inter", "Cantarell", "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 26px 26px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-mark { width: 34px; height: 34px; filter: drop-shadow(0 2px 8px rgb(152 150 224 / .35)); }
|
||||
.brand-text h1 { font-size: 20px; font-weight: 700; letter-spacing: .2px; }
|
||||
.tagline { font-size: 12px; color: var(--fg-dim); }
|
||||
|
||||
.header-status { display: flex; align-items: center; gap: 8px; }
|
||||
.engine-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--off);
|
||||
box-shadow: 0 0 8px rgb(107 114 128 / .6);
|
||||
transition: background .3s, box-shadow .3s;
|
||||
}
|
||||
.engine-dot.ok { background: var(--ok); box-shadow: 0 0 10px rgb(34 197 94 / .7); }
|
||||
.engine-dot.warn { background: var(--warn); box-shadow: 0 0 10px rgb(245 158 11 / .7); }
|
||||
.engine-dot.bad { background: var(--bad); box-shadow: 0 0 10px rgb(239 68 68 / .7); }
|
||||
.engine-label { font-size: 12.5px; color: var(--fg-dim); }
|
||||
|
||||
/* ── Tabs ───────────────────────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tab {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: 600 13px/1 inherit;
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
position: relative;
|
||||
}
|
||||
.tab:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.tab.active {
|
||||
background: linear-gradient(180deg, #232B40, #1B2233);
|
||||
border-color: var(--border-hi);
|
||||
color: var(--fg);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / .04);
|
||||
}
|
||||
.tab svg { opacity: .8; }
|
||||
.tab-badge {
|
||||
min-width: 18px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--bad);
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tab-badge.quiet { background: var(--warn); }
|
||||
|
||||
.tab-panel { display: none; flex-direction: column; gap: 18px; }
|
||||
.tab-panel.active { display: flex; }
|
||||
|
||||
/* ── Hero ───────────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
background: linear-gradient(180deg, var(--bg-panel), var(--bg-raised));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 26px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.shield {
|
||||
flex: 0 0 auto;
|
||||
width: 84px; height: 84px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(152 150 224 / .07);
|
||||
border: 1px solid rgb(152 150 224 / .18);
|
||||
transition: border-color .3s, background .3s;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.06); opacity: .85; }
|
||||
}
|
||||
.hero-copy h2 { font-size: 22px; font-weight: 700; }
|
||||
.hero-copy p { color: var(--fg-dim); margin: 4px 0 12px; }
|
||||
|
||||
.hero[data-state="protected"] .shield { border-color: rgb(34 197 94 / .35); background: rgb(34 197 94 / .08); }
|
||||
.hero[data-state="scanning"] .shield { border-color: rgb(245 158 11 / .4); background: rgb(245 158 11 / .09); }
|
||||
.hero[data-state="threat"] .shield { border-color: rgb(239 68 68 / .45); background: rgb(239 68 68 / .1); }
|
||||
.hero[data-state="paused"] .shield { border-color: rgb(107 114 128 / .4); background: rgb(107 114 128 / .09); }
|
||||
.hero[data-state="scanning"] #shield-icon { animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 11px;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.stale { color: var(--warn); border-color: rgb(245 158 11 / .4); }
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────────── */
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-hi);
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
font: 600 13.5px/1 inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 16px;
|
||||
cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
transition: background .15s, border-color .15s, transform .05s;
|
||||
}
|
||||
.btn:hover { background: #232C3E; border-color: #41507a; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, #5A58D6, #4543C4);
|
||||
border-color: #6a68e6;
|
||||
}
|
||||
.btn.primary:hover { background: linear-gradient(180deg, #6765e0, #4f4dd4); }
|
||||
.btn.small { padding: 7px 12px; font-size: 12.5px; }
|
||||
.btn.danger { border-color: rgb(239 68 68 / .5); }
|
||||
.btn.danger:hover { background: rgb(239 68 68 / .15); border-color: var(--bad); }
|
||||
|
||||
/* ── Panels ─────────────────────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
.panel-head h3 { font-size: 14px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; color: var(--fg-dim); }
|
||||
.muted { color: var(--fg-dim); font-size: 13px; }
|
||||
.empty { padding: 6px 0 2px; }
|
||||
.hidden { display: none !important; }
|
||||
.row-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.scan-target { font-family: ui-monospace, monospace; font-size: 12.5px; color: var(--fg-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* scan progress */
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-hover);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
border-radius: inherit;
|
||||
transition: width .4s ease;
|
||||
}
|
||||
.progress.indeterminate .progress-bar {
|
||||
width: 35%;
|
||||
animation: slide 1.3s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes slide { from { margin-left: 0; } to { margin-left: 65%; } }
|
||||
|
||||
/* results */
|
||||
.results { display: flex; flex-direction: column; gap: 6px; }
|
||||
.result-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.result-row .sig { font-weight: 700; font-size: 13.5px; }
|
||||
.result-row .path { color: var(--fg-dim); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
|
||||
.result-row.clean { border-left: 3px solid var(--ok); }
|
||||
.result-row.clean .sig { color: var(--ok); }
|
||||
.result-row.infected { border-left: 3px solid var(--bad); }
|
||||
.result-row.infected .sig { color: var(--bad); }
|
||||
|
||||
/* log */
|
||||
.log {
|
||||
background: #0A0D13;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
font: 12px/1.6 ui-monospace, "JetBrains Mono", monospace;
|
||||
color: var(--fg-dim);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.log.ok { color: #7fd79a; }
|
||||
.log.fail { color: #f2a2a2; }
|
||||
|
||||
/* ── Inputs & switches ──────────────────────────────────────────── */
|
||||
.input {
|
||||
appearance: none;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: 13.5px/1.4 inherit;
|
||||
padding: 9px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgb(152 150 224 / .15); }
|
||||
.input.mono { font-family: ui-monospace, "JetBrains Mono", monospace; font-size: 12.5px; }
|
||||
textarea.input { resize: vertical; }
|
||||
select.input { cursor: pointer; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 200px auto; gap: 10px; }
|
||||
|
||||
.field { margin: 14px 0; }
|
||||
.field > label { display: block; font-size: 13px; font-weight: 600; color: var(--fg-dim); margin-bottom: 6px; }
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.switch-row.setting { padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.switch-row.setting:last-of-type { border-bottom: none; }
|
||||
.switch-row strong { display: block; font-size: 14px; font-weight: 600; }
|
||||
.switch-row small { display: block; color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
|
||||
.switch-row input[type="checkbox"] { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.switch {
|
||||
flex: 0 0 auto;
|
||||
width: 40px; height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-hi);
|
||||
position: relative;
|
||||
transition: background .2s, border-color .2s;
|
||||
}
|
||||
.switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg-dim);
|
||||
transition: transform .2s, background .2s;
|
||||
}
|
||||
.switch-row input:checked + .switch { background: rgb(90 88 214 / .55); border-color: #6a68e6; }
|
||||
.switch-row input:checked + .switch::after { transform: translateX(18px); background: #fff; }
|
||||
|
||||
/* ── Quarantine ─────────────────────────────────────────────────── */
|
||||
.qt-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.qt-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--bad);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.qt-row.restored { border-left-color: var(--off); opacity: .75; }
|
||||
.qt-row .qt-virus { font-weight: 700; font-size: 13px; color: var(--bad); white-space: nowrap; }
|
||||
.qt-row.restored .qt-virus { color: var(--fg-dim); }
|
||||
.qt-row .qt-path { font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qt-row .qt-meta { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; }
|
||||
.qt-row .qt-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Realtime ───────────────────────────────────────────────────── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-val { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.stat-val.alarm { color: var(--bad); }
|
||||
.stat-val.watching { color: var(--warn); }
|
||||
.stat-val.calm { color: var(--ok); }
|
||||
.stat-key { font-size: 11.5px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .4px; }
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv-k { flex: 0 0 150px; color: var(--fg-faint); }
|
||||
.kv-v { color: var(--fg-dim); font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
|
||||
|
||||
.watch-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.watch-chip {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
}
|
||||
|
||||
/* ── Rootkit ────────────────────────────────────────────────────── */
|
||||
.verdict {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.verdict.clean { background: rgb(34 197 94 / .08); border-color: rgb(34 197 94 / .35); color: #7fd79a; }
|
||||
.verdict.dirty { background: rgb(239 68 68 / .08); border-color: rgb(239 68 68 / .4); color: #f2a2a2; }
|
||||
|
||||
.finding-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.finding-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.finding-row .sev {
|
||||
font-size: 10.5px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .5px;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
}
|
||||
.sev.critical { background: rgb(239 68 68 / .15); color: var(--bad); }
|
||||
.sev.warn { background: rgb(245 158 11 / .15); color: var(--warn); }
|
||||
.sev.info { background: rgb(107 114 128 / .2); color: var(--fg-dim); }
|
||||
.finding-row .check { font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg-dim); }
|
||||
.finding-row .detail { font-size: 13px; color: var(--fg); word-break: break-all; }
|
||||
|
||||
/* ── Alerts ─────────────────────────────────────────────────────── */
|
||||
.event-list { display: flex; flex-direction: column; gap: 4px; max-height: 520px; overflow-y: auto; }
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.event-row .ev-sev { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; width: 64px; }
|
||||
.ev-sev.critical { color: var(--bad); }
|
||||
.ev-sev.warn { color: var(--warn); }
|
||||
.ev-sev.info { color: var(--fg-faint); }
|
||||
.event-row .ev-kind { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--fg-dim); width: 96px; }
|
||||
.event-row .ev-msg { font-size: 13px; color: var(--fg); word-break: break-word; }
|
||||
.event-row .ev-ts { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* footer */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--fg-faint);
|
||||
padding-top: 4px;
|
||||
}
|
||||
280
gui/package-lock.json
generated
Normal file
280
gui/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
{
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
"@tauri-apps/cli": "^2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0"
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
gui/src-tauri/Cargo.lock
generated
117
gui/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
13
gui/src-tauri/capabilities/default.json
Normal file
13
gui/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[
|
||||
{
|
||||
"identifier": "default",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"dialog:default",
|
||||
"notification:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -4,25 +4,40 @@
|
|||
//! The system-tray sentinel swaps between the four state icons:
|
||||
//!
|
||||
//! protected (green) / scanning (amber) / threat (red) / paused (gray)
|
||||
//!
|
||||
//! A background watcher polls the daemon once a second: it keeps the tray in
|
||||
//! step with daemon-side state (realtime threat → red, scan in flight →
|
||||
//! amber) and fires a desktop notification for every *new* critical event
|
||||
//! the daemon logs (quarantine, ransomware alarm, rootkit finding).
|
||||
|
||||
use hound_api::{Client, ScanResult, Status, UpdateResult};
|
||||
use hound_api::{
|
||||
Client, Event, RealtimeStatus, RootkitScan, ScanResult, Settings, Status, UpdateResult,
|
||||
QuarantineEntry,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tauri_plugin_notification::NotificationExt as _;
|
||||
|
||||
type R<T> = anyhow::Result<T>;
|
||||
|
||||
const TRAY_ID: &str = "hound-tray";
|
||||
const POLL_SECS: u64 = 1;
|
||||
|
||||
/// Shared between the `scan` command (sets it) and the tray watcher (reads
|
||||
/// it) so the sentinel shows "scanning" while an on-demand scan is running.
|
||||
static SCANNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// States the tray can render. Anything unknown falls back to `protected`.
|
||||
const STATES: [&str; 4] = ["protected", "scanning", "threat", "paused"];
|
||||
|
||||
/// The four preloaded state icons, managed so tray swaps never hit disk.
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
struct TrayIcons(HashMap<String, Image<'static>>);
|
||||
|
||||
fn client() -> Client {
|
||||
|
|
@ -43,10 +58,12 @@ async fn status() -> Result<Status, String> {
|
|||
#[tauri::command]
|
||||
async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
|
||||
SCANNING.store(true, Ordering::Relaxed);
|
||||
let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
SCANNING.store(false, Ordering::Relaxed);
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -58,30 +75,135 @@ async fn update() -> Result<UpdateResult, String> {
|
|||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn settings() -> Result<Settings, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.settings())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_settings(s: Settings) -> Result<Settings, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.set_settings(&s))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn events(limit: u32) -> Result<Vec<Event>, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.events(limit))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn clear_events() -> Result<u64, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.clear_events())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn quarantine_list() -> Result<Vec<QuarantineEntry>, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.quarantine_list())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn quarantine_add(path: String, virus: String) -> Result<QuarantineEntry, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.quarantine_add(&path, &virus))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn quarantine_remove(id: String) -> Result<u64, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn rootkit_scan() -> Result<RootkitScan, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.rootkit_scan())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn realtime_status() -> Result<RealtimeStatus, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.realtime_status())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> {
|
||||
let c = client();
|
||||
tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Swap the tray icon + tooltip. The window calls this to keep the sentinel
|
||||
/// in step with what the user is doing (e.g. it starts a scan).
|
||||
/// in step with what the user is doing (e.g. it starts a scan). The backend
|
||||
/// watcher also calls the same routine as daemon state changes underneath.
|
||||
fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
|
||||
let state = if STATES.contains(&state) {
|
||||
state
|
||||
} else {
|
||||
"protected"
|
||||
};
|
||||
let img = match icons.0.get(state) {
|
||||
Some(i) => i.clone(),
|
||||
None => icons
|
||||
.0
|
||||
.get("protected")
|
||||
.cloned()
|
||||
.expect("protected icon always loaded"),
|
||||
};
|
||||
if let Some(tray) = app.tray_by_id(TRAY_ID) {
|
||||
let _ = tray.set_icon(Some(img));
|
||||
let _ = tray.set_tooltip(Some(tooltip_for(state)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_state(
|
||||
app: tauri::AppHandle,
|
||||
icons: State<'_, TrayIcons>,
|
||||
state: String,
|
||||
) -> Result<(), String> {
|
||||
let state = if STATES.contains(&state.as_str()) {
|
||||
state
|
||||
} else {
|
||||
"protected".into()
|
||||
};
|
||||
let img = icons.0.get(&state).cloned().unwrap_or_else(|| {
|
||||
icons
|
||||
.0
|
||||
.get("protected")
|
||||
.cloned()
|
||||
.expect("protected icon always loaded")
|
||||
});
|
||||
if let Some(tray) = app.tray_by_id(TRAY_ID) {
|
||||
let _ = tray.set_icon(Some(img));
|
||||
let _ = tray.set_tooltip(Some(tooltip_for(&state)));
|
||||
}
|
||||
apply_state(&app, &icons, &state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +216,64 @@ fn tooltip_for(state: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Tray watcher: state sync + critical-event notifications ─────────────────
|
||||
|
||||
/// Poll the daemon; reflect daemon-side state in the tray and notify on new
|
||||
/// critical events the frontend hasn't already surfaced. Runs as a plain
|
||||
/// OS thread: one blocking client round-trip per tick is exactly what this
|
||||
/// wants, and it keeps the command side free of scheduler gymnastics.
|
||||
fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
|
||||
std::thread::spawn(move || {
|
||||
let mut last_seen_id: u64 = 0;
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(POLL_SECS));
|
||||
let c = client();
|
||||
if c.status().is_err() {
|
||||
continue; // daemon restarting — skip the tick
|
||||
}
|
||||
let paused = c
|
||||
.settings()
|
||||
.map(|s| s.paused)
|
||||
.unwrap_or(false);
|
||||
// Derive tray state: a fresh critical event wins, then an
|
||||
// in-flight scan, then paused, then protected.
|
||||
let mut state = "protected";
|
||||
if let Ok(evts) = c.events(20) {
|
||||
if let Some(latest) = evts.first() {
|
||||
if latest.severity == "critical" && latest.id > last_seen_id {
|
||||
last_seen_id = latest.id;
|
||||
state = "threat";
|
||||
notify(&app, latest);
|
||||
}
|
||||
}
|
||||
}
|
||||
if state == "protected" {
|
||||
if SCANNING.load(Ordering::Relaxed) {
|
||||
state = "scanning";
|
||||
} else if paused {
|
||||
state = "paused";
|
||||
}
|
||||
}
|
||||
apply_state(&app, &icons, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn notify(app: &tauri::AppHandle, ev: &Event) {
|
||||
let title = match ev.kind.as_str() {
|
||||
"quarantine" | "threat" => "Hound — threat quarantined",
|
||||
"ransomware" => "Hound — ransomware suspected",
|
||||
"rootkit" => "Hound — rootkit activity",
|
||||
_ => "Hound — alert",
|
||||
};
|
||||
let notification = app.notification();
|
||||
let _ = notification
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(&ev.message)
|
||||
.show();
|
||||
}
|
||||
|
||||
// ── Icon resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
fn icon_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
|
|
@ -136,8 +316,25 @@ fn load_icon(path: &Path) -> R<Image<'static>> {
|
|||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![status, scan, update, set_state])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
status,
|
||||
scan,
|
||||
update,
|
||||
settings,
|
||||
set_settings,
|
||||
events,
|
||||
clear_events,
|
||||
quarantine_list,
|
||||
quarantine_add,
|
||||
quarantine_restore,
|
||||
quarantine_remove,
|
||||
rootkit_scan,
|
||||
realtime_status,
|
||||
realtime_set_enabled,
|
||||
set_state
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let icons = TrayIcons(load_state_icons(&handle)?);
|
||||
|
|
@ -146,6 +343,7 @@ pub fn run() {
|
|||
.get("protected")
|
||||
.expect("protected icon loaded")
|
||||
.clone();
|
||||
let watcher_icons = icons.clone();
|
||||
app.manage(icons);
|
||||
|
||||
let open = MenuItem::with_id(&handle, "open", "Open Hound", true, None::<&str>)?;
|
||||
|
|
@ -211,6 +409,8 @@ pub fn run() {
|
|||
})
|
||||
.build(&handle)?;
|
||||
|
||||
start_watcher(handle.clone(), watcher_icons);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
|
|
|||
Loading…
Reference in a new issue