diff --git a/crates/houndd/src/caps.rs b/crates/houndd/src/caps.rs index 7393c71..d2c436d 100644 --- a/crates/houndd/src/caps.rs +++ b/crates/houndd/src/caps.rs @@ -53,14 +53,26 @@ use std::io; pub const CAP_DAC_OVERRIDE: u32 = 1; pub const CAP_DAC_READ_SEARCH: u32 = 2; pub const CAP_FOWNER: u32 = 3; +pub const CAP_SETGID: u32 = 6; +pub const CAP_SETUID: u32 = 7; pub const CAP_SYS_ADMIN: u32 = 21; /// Everything the daemon needs and nothing else. -pub const GATE_CAPS: [u32; 4] = [ +/// +/// SETUID/SETGID are here for one purpose: answering "could the user who +/// asked have read this file?" by forking a child, becoming that user, and +/// letting `access(2)` decide (see peer.rs). Next to CAP_DAC_OVERRIDE and +/// CAP_DAC_READ_SEARCH — which already let this process read and write every +/// file on the system — the ability to become another user widens nothing +/// that matters, and it is what keeps the scanner from being an oracle for +/// files its caller cannot open. +pub const GATE_CAPS: [u32; 6] = [ CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH, CAP_DAC_OVERRIDE, CAP_FOWNER, + CAP_SETUID, + CAP_SETGID, ]; /// Highest capability the running kernel could define. 63 is the ceiling @@ -248,9 +260,9 @@ mod tests { } #[test] - fn the_gate_set_is_exactly_four_bits() { + fn the_gate_set_is_exactly_six_bits() { let (lo, hi) = to_words(&GATE_CAPS); - assert_eq!(lo.count_ones(), 4, "no capability may sneak in"); + assert_eq!(lo.count_ones(), 6, "no capability may sneak in"); assert_eq!(hi, 0); } @@ -266,7 +278,14 @@ mod tests { const CAP_NET_ADMIN: u32 = 12; const CAP_NET_RAW: u32 = 13; const CAP_AUDIT_CONTROL: u32 = 30; - const CAP_SETUID: u32 = 7; + // CAP_SETUID was on this list until the desktop app needed a way to + // ask "could the user who requested this scan have read the file?". + // Answering it means becoming that user in a child process. It is a + // deliberate reversal, and it is defensible only because the two + // capabilities immediately below it — DAC_OVERRIDE and + // DAC_READ_SEARCH — already grant this process every file on the + // machine. The ones still listed here grant things it has no other + // route to. let (lo, _) = to_words(&GATE_CAPS); for (name, bit) in [ ("CAP_SYS_MODULE", CAP_SYS_MODULE), @@ -275,7 +294,6 @@ mod tests { ("CAP_NET_ADMIN", CAP_NET_ADMIN), ("CAP_NET_RAW", CAP_NET_RAW), ("CAP_AUDIT_CONTROL", CAP_AUDIT_CONTROL), - ("CAP_SETUID", CAP_SETUID), ] { assert_eq!(lo & (1 << bit), 0, "{name} must never be retained"); } @@ -320,7 +338,7 @@ mod tests { #[test] fn the_gate_set_matches_what_proc_would_report() { let (lo, _) = to_words(&GATE_CAPS); - assert_eq!(lo as u64, 0x20_000e, "must match the CapEff mask in /proc"); + assert_eq!(lo as u64, 0x20_00ce, "must match the CapEff mask in /proc"); } #[test] diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 96f9780..8fc9181 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -47,6 +47,7 @@ mod engine; mod events; mod fanotify; mod native; +mod peer; mod persistence; mod quarantine; mod realtime; @@ -63,7 +64,8 @@ use serde_json::Value; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::PathBuf; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -94,6 +96,14 @@ 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}"))?; + // The desktop app runs as the logged-in user and has to reach the daemon + // without a password prompt on every status poll, so the socket is opened + // to the `hound` group. What that group can actually do is decided per + // method in `peer::access_for` — read-only, with anything that writes + // still requiring uid 0. If the group does not exist (running from a + // build tree rather than the package) the socket stays root-only, which + // is the old behaviour and safe. + open_socket_to_hound_group(&sock_path); let state = DaemonState::boot(); @@ -271,7 +281,7 @@ impl DaemonState { "info", "capabilities reduced to the four the gate needs".into(), ); - eprintln!("gate: capabilities reduced to 4 of 41 (CapEff 0x20000e)"); + eprintln!("gate: capabilities reduced to 6 of 41 (CapEff 0x2000ce)"); } Ok(false) => {} Err(e) => { @@ -398,6 +408,46 @@ impl DaemonState { } } +/// Hand the socket to the `hound` group at 0660, if that group exists. +/// Best effort by design: a daemon that refuses to start because a group is +/// missing is worse than one that starts root-only. +fn open_socket_to_hound_group(sock_path: &Path) { + let Some(gid) = hound_group_gid() else { + eprintln!("socket: no `hound` group on this system — staying root-only"); + return; + }; + let Ok(c_path) = std::ffi::CString::new(sock_path.as_os_str().as_encoded_bytes()) else { + return; + }; + // SAFETY: c_path is a valid NUL-terminated path that outlives the call. + // -1 for the uid means "leave the owner alone". + if unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) } != 0 { + eprintln!( + "socket: cannot chgrp to hound: {}", + std::io::Error::last_os_error() + ); + return; + } + if let Err(e) = fs::set_permissions(sock_path, fs::Permissions::from_mode(0o660)) { + eprintln!("socket: cannot set mode 0660: {e}"); + return; + } + eprintln!("socket: readable by group hound (gid {gid}); writes still require root"); +} + +fn hound_group_gid() -> Option { + let group = fs::read_to_string("/etc/group").ok()?; + for line in group.lines() { + let mut f = line.split(':'); + if f.next()? != "hound" { + continue; + } + let _pw = f.next(); + return f.next()?.parse().ok(); + } + None +} + /// Read one request line, dispatch, write one response line. fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> { let mut reader = BufReader::new(stream); @@ -406,7 +456,12 @@ fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> { let req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?; - let result = dispatch(&req, &state); + // Who is asking, per the kernel — not per anything in the request. + let who = peer::Peer::from_stream(reader.get_ref())?; + let result = match authorise(&who, &req) { + Ok(()) => dispatch(&req, &state), + Err(denied) => Err(denied), + }; let resp = match result { Ok(value) => Response { jsonrpc: "2.0".into(), @@ -439,6 +494,38 @@ fn writer_flush(reader: &mut BufReader, bytes: &str) -> Result<()> { Ok(()) } +/// Gate a request on the peer's credentials. Returns the error the client +/// will see, phrased so a person knows what to do about it. +fn authorise(who: &peer::Peer, req: &hound_api::Request) -> Result<()> { + match peer::access_for(&req.method) { + peer::Access::Read => Ok(()), + peer::Access::Admin if who.may_administer() => Ok(()), + peer::Access::Admin => bail!( + "{} requires administrator privileges — run it with sudo", + req.method + ), + peer::Access::ReadsPath => { + if who.is_root() { + return Ok(()); + } + let path = req + .params + .as_ref() + .and_then(|p| p.get("path")) + .and_then(Value::as_str) + .with_context(|| format!("{} requires params.path", req.method))?; + // The daemon reads as root. Confirming the caller could have read + // the path itself is what stops the scanner being used as an + // oracle for files the caller cannot open. + if peer::peer_can_read(who, std::path::Path::new(path)) { + Ok(()) + } else { + bail!("cannot read {path} as uid {} — scan it with sudo", who.uid) + } + } + } +} + fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { match req.method.as_str() { "status" => Ok(serde_json::to_value(status(st)?)?), diff --git a/crates/houndd/src/peer.rs b/crates/houndd/src/peer.rs new file mode 100644 index 0000000..f74531d --- /dev/null +++ b/crates/houndd/src/peer.rs @@ -0,0 +1,359 @@ +//! Who is on the other end of the socket, and what they may ask for. +//! +//! The daemon runs as root, so every method it exposes runs as root. Until +//! the desktop app existed that was academic — the socket was `0700 root:root` +//! and only `sudo hound` could reach it. But a GUI runs as the logged-in user +//! and has to reach the daemon without a password prompt on every status poll, +//! so the socket is now group-readable by `hound`. +//! +//! That group is a real trust grant and it is worth being exact about how far +//! it goes. `quarantine.add` moves any path into the vault, and +//! `quarantine.restore` writes a file back out as root — between them that is +//! arbitrary file replacement on the system, which is root. Group members +//! therefore get the read side of the API and nothing that writes: +//! administrative methods still require a peer with uid 0. +//! +//! Scanning is the exception worth explaining. It reads as root, so an +//! unprivileged caller could otherwise use it as an oracle for files it cannot +//! open. So a non-root caller's scan is checked against what *that caller* +//! could read, by forking a child, dropping it to the peer's uid, gid and +//! supplementary groups, and letting the kernel answer with `access(2)` — +//! which honours ACLs and mount options, unlike anything we could reconstruct +//! from a mode bitfield. + +use anyhow::{bail, Result}; +use std::ffi::CString; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::UnixStream; +use std::path::Path; + +/// The credentials the kernel attaches to a connection. Unforgeable: these +/// come from `SO_PEERCRED`, not from anything the client sent us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Peer { + pub pid: i32, + pub uid: u32, + pub gid: u32, +} + +impl Peer { + pub fn is_root(&self) -> bool { + self.uid == 0 + } + + /// May this peer use the methods that write? + /// + /// Root, or whoever the daemon is running as. The second clause is not a + /// loophole: a user running `houndd` under their own account can already + /// kill it, attach a debugger to it, or edit its settings file directly, + /// so refusing them the API buys nothing and breaks every developer and + /// test run. In the shipped configuration the daemon is uid 0, and this + /// collapses to exactly "the peer is root". + pub fn may_administer(&self) -> bool { + // SAFETY: getuid takes no arguments and cannot fail. + self.is_root() || self.uid == unsafe { libc::getuid() } + } + + pub fn from_stream(stream: &UnixStream) -> Result { + let mut cred = libc::ucred { + pid: 0, + uid: u32::MAX, + gid: u32::MAX, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: cred and len are correctly sized for SO_PEERCRED, and the fd + // is owned by the borrowed stream for the duration of the call. + let rc = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + if rc != 0 { + bail!( + "reading peer credentials: {}", + std::io::Error::last_os_error() + ); + } + Ok(Peer { + pid: cred.pid, + uid: cred.uid, + gid: cred.gid, + }) + } +} + +/// What a method costs the caller in trust. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + /// Reports the daemon's own state. Anyone who can open the socket. + Read, + /// Reads a caller-supplied path as root; allowed once we have confirmed + /// the caller could have read it themselves. + ReadsPath, + /// Changes the system, or the daemon's configuration. Root only. + Admin, +} + +pub fn access_for(method: &str) -> Access { + match method { + "status" + | "settings.get" + | "events.list" + | "quarantine.list" + | "realtime.status" + | "rootkit.scan" + | "persistence.scan" => Access::Read, + + "scan" | "supply.sweep" => Access::ReadsPath, + + // Everything that writes: definitions updates, settings, the event + // log, and every quarantine mutation. + _ => Access::Admin, + } +} + +/// Can `peer` read `path` in its own right? Answered by the kernel, in a child +/// process that has actually become the peer. +/// +/// Errs on the side of refusal: a fork failure, a child that dies on a signal, +/// or any unexpected exit status is a no. +pub fn peer_can_read(peer: &Peer, path: &Path) -> bool { + if peer.is_root() { + return true; + } + let Ok(c_path) = CString::new(path.as_os_str().as_encoded_bytes()) else { + return false; // an interior NUL is not a path we will scan + }; + + // Impersonating someone requires being root. The packaged daemon is, but + // a developer running `houndd` from a build tree is not — and there the + // only peer it can answer for is itself. + // SAFETY: geteuid takes no arguments and cannot fail. + if unsafe { libc::geteuid() } != 0 { + // SAFETY: as above. + if unsafe { libc::getuid() } != peer.uid { + return false; + } + // SAFETY: c_path is NUL-terminated and outlives the call. + return unsafe { libc::access(c_path.as_ptr(), libc::R_OK) } == 0; + } + + let groups = supplementary_groups(peer.uid, peer.gid); + + // SAFETY: between fork and _exit the child calls only async-signal-safe + // functions — setgroups, setgid, setuid, access, _exit. It allocates + // nothing and unwinds nothing. + let pid = unsafe { libc::fork() }; + if pid < 0 { + return false; + } + if pid == 0 { + unsafe { + // Order matters: setgroups and setgid must precede setuid, or the + // child no longer has the privilege to make those calls. + if libc::setgroups(groups.len() as _, groups.as_ptr()) != 0 { + libc::_exit(101); + } + if libc::setgid(peer.gid) != 0 { + libc::_exit(102); + } + if libc::setuid(peer.uid) != 0 { + libc::_exit(103); + } + // Belt and braces: if the drop somehow left us root, refuse. + if libc::getuid() == 0 || libc::geteuid() == 0 { + libc::_exit(104); + } + let ok = libc::access(c_path.as_ptr(), libc::R_OK) == 0; + libc::_exit(if ok { 0 } else { 1 }); + } + } + + let mut status = 0; + // SAFETY: pid is a child we just created; status is a valid out-param. + if unsafe { libc::waitpid(pid, &mut status, 0) } != pid { + return false; + } + if !libc::WIFEXITED(status) { + return false; + } + match libc::WEXITSTATUS(status) { + 0 => true, + 1 => false, + // The child could not become the peer, so the question was never + // asked. Refuse — but say so, because a daemon that has lost + // CAP_SETUID will refuse every non-root scan and the reason should + // not have to be guessed from a permission error. + code => { + eprintln!( + "peer check: could not drop to uid {} (stage {code}) — refusing to \ + vouch for a read this daemon cannot verify", + peer.uid + ); + false + } + } +} + +/// The peer's supplementary groups, so the check honours group-readable files. +/// Falls back to the primary gid alone if the user cannot be resolved. +fn supplementary_groups(uid: u32, gid: u32) -> Vec { + let Some(name) = username_for(uid) else { + return vec![gid]; + }; + let Ok(c_name) = CString::new(name) else { + return vec![gid]; + }; + let mut ngroups: libc::c_int = 32; + let mut groups: Vec = vec![0; ngroups as usize]; + // SAFETY: c_name outlives the call; groups has ngroups capacity. A -1 + // return rewrites ngroups with the count actually needed, so we retry once. + let rc = unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) }; + if rc < 0 { + groups = vec![0; ngroups.max(1) as usize]; + // SAFETY: as above, now with the size the kernel asked for. + if unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) } < 0 + { + return vec![gid]; + } + } + groups.truncate(ngroups.max(0) as usize); + if groups.is_empty() { + groups.push(gid); + } + groups +} + +/// Resolve a uid to a login name via /etc/passwd. Reading the file directly +/// keeps this free of NSS, which can block on a network directory service — +/// not something a scan request should ever wait on. +fn username_for(uid: u32) -> Option { + let passwd = std::fs::read_to_string("/etc/passwd").ok()?; + for line in passwd.lines() { + let mut f = line.split(':'); + let name = f.next()?; + let _pw = f.next(); + let this_uid: u32 = f.next()?.parse().ok()?; + if this_uid == uid { + return Some(name.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_are_open_and_writes_are_not() { + assert_eq!(access_for("status"), Access::Read); + assert_eq!(access_for("quarantine.list"), Access::Read); + assert_eq!(access_for("scan"), Access::ReadsPath); + assert_eq!(access_for("supply.sweep"), Access::ReadsPath); + for admin in [ + "update", + "settings.set", + "events.clear", + "quarantine.add", + "quarantine.restore", + "quarantine.remove", + "realtime.set_enabled", + ] { + assert_eq!(access_for(admin), Access::Admin, "{admin} must require root"); + } + } + + /// An unknown method must not fall through to the permissive arm. If + /// someone adds a mutating method and forgets to classify it, it lands + /// in Admin and fails closed rather than silently becoming public. + #[test] + fn unclassified_methods_fail_closed() { + assert_eq!(access_for("some.future.method"), Access::Admin); + assert_eq!(access_for(""), Access::Admin); + } + + /// The owning user is an administrator of their own daemon; anybody else + /// is not. Under a root daemon the two clauses are the same clause. + #[test] + fn the_owner_administers_and_strangers_do_not() { + let me = unsafe { libc::getuid() }; + assert!(Peer { pid: 1, uid: 0, gid: 0 }.may_administer(), "root always"); + assert!(Peer { pid: 1, uid: me, gid: 0 }.may_administer(), "the owner"); + let stranger = if me == 0 { 1000 } else { me + 1 }; + assert!( + !Peer { pid: 1, uid: stranger, gid: 0 }.may_administer(), + "a different unprivileged user must not administer the daemon" + ); + } + + #[test] + fn root_peer_reads_anything() { + let root = Peer { + pid: 1, + uid: 0, + gid: 0, + }; + assert!(peer_can_read(&root, Path::new("/etc/shadow"))); + } + + /// The check is only meaningful if it can say no. Running as an + /// unprivileged user, /etc/shadow is the canonical unreadable file; + /// running as root there is nothing to prove, so skip. This exercises + /// the non-root branch, where the daemon can only answer for itself. + #[test] + fn unprivileged_peer_is_refused_a_file_it_cannot_open() { + let uid = unsafe { libc::getuid() }; + if uid == 0 { + return; + } + let gid = unsafe { libc::getgid() }; + let me = Peer { + pid: std::process::id() as i32, + uid, + gid, + }; + if Path::new("/etc/shadow").exists() { + assert!(!peer_can_read(&me, Path::new("/etc/shadow"))); + } + // ...and it must still say yes to something the peer owns. + let mine = std::env::temp_dir().join(format!("hound-peer-{uid}-{}", std::process::id())); + std::fs::write(&mine, b"x").unwrap(); + assert!(peer_can_read(&me, &mine)); + let _ = std::fs::remove_file(&mine); + } + + /// An unprivileged daemon cannot impersonate anybody, and must refuse + /// rather than answer a question it has no way to check. + #[test] + fn an_unprivileged_daemon_refuses_to_vouch_for_another_user() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let someone_else = Peer { + pid: 1, + uid: unsafe { libc::getuid() } + 1, + gid: 0, + }; + let readable = std::env::temp_dir(); + assert!(!peer_can_read(&someone_else, &readable)); + } + + #[test] + fn a_missing_file_is_not_readable() { + let uid = unsafe { libc::getuid() }; + if uid == 0 { + return; + } + let me = Peer { + pid: 1, + uid, + gid: unsafe { libc::getgid() }, + }; + assert!(!peer_can_read(&me, Path::new("/nonexistent/hound/path"))); + } +} diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 7b72dd8..661dd0c 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ diff --git a/gui/dist/app.js b/gui/dist/app.js index 72694a2..dff930e 100644 --- a/gui/dist/app.js +++ b/gui/dist/app.js @@ -1,10 +1,41 @@ // 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"; +// No bundler here — the webview loads this file directly, so a bare module +// specifier ("@tauri-apps/api/core") cannot be resolved and the entire script +// fails to parse. The window then sits on its static HTML forever, looking +// exactly like a daemon that never answered. The API comes off the global +// that `withGlobalTauri` installs instead. +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; const $ = (id) => document.getElementById(id); +// The daemon refuses anything that writes unless the caller is root, because +// quarantine restores files back out as root and that is not something group +// membership should confer. The app runs as you, so those actions need a +// terminal for now. Translate the wire error rather than showing it raw. +function explain(e) { + const raw = String(e && e.message ? e.message : e); + const m = raw.match(/([a-z.]+) requires administrator privileges/); + if (m) { + return "That needs administrator rights. Run it from a terminal: sudo hound " + + ({ + "settings.set": "settings …", + "update": "update", + "quarantine.add": "quarantine add ", + "quarantine.restore": "quarantine restore ", + "quarantine.remove": "quarantine remove ", + "events.clear": "alerts clear", + "realtime.set_enabled": "settings realtime on|off", + }[m[1]] || m[1]); + } + if (/cannot read .* as uid/.test(raw)) { + return raw.replace(/^.*?cannot read/, "Hound will not scan") + + " (it only scans files you could open yourself)."; + } + return raw.replace(/^daemon error -?\d+: /, ""); +} + const ICONS = { protected: "state-protected-48.png", scanning: "state-scanning-48.png", @@ -83,6 +114,9 @@ function renderStatus(st) { st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline"); $("pill-os").textContent = "OS: " + (st.os || "—"); $("pill-engine").textContent = "engine: " + (st.engine || "—"); + // The footer used to claim ClamAV. It reports whatever the daemon actually + // loaded, which has been yara-x since the engine was replaced. + $("foot-engine").textContent = "engine: " + (st.engine || "—"); const pill = $("pill-db"); if (st.db) { pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at); @@ -110,7 +144,7 @@ async function doScan(path) { const r = await invoke("scan", { path, recursive: true }); renderScanResult(r, path); } catch (e) { - $("scan-note").textContent = "Scan failed: " + String(e); + $("scan-note").textContent = "Scan failed: " + explain(e); setState(paused ? "paused" : "protected"); } finally { busy = false; @@ -175,7 +209,7 @@ async function doUpdate() { if (u.status) renderStatus(u.status); setState(paused ? "paused" : "protected"); } catch (e) { - log.textContent = "Update failed: " + String(e); + log.textContent = "Update failed: " + explain(e); log.className = "log fail"; } finally { busy = false; @@ -252,7 +286,7 @@ async function loadQuarantine() { body.appendChild(row); } } catch (e) { - $("qt-body").innerHTML = `

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

`; + $("qt-body").innerHTML = `

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

`; } } @@ -289,7 +323,7 @@ async function loadRealtime() { wl.appendChild(chip); } } catch (e) { - $("rt-enabled-label").textContent = "error: " + String(e); + $("rt-enabled-label").textContent = "error: " + explain(e); } } @@ -326,7 +360,7 @@ async function runRootkit() { body.appendChild(row); } } catch (e) { - $("rootkit-body").innerHTML = `

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

`; + $("rootkit-body").innerHTML = `

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

`; } finally { btn.disabled = false; btn.textContent = "Run Scan"; @@ -362,7 +396,7 @@ async function loadAlerts() { body.appendChild(row); } } catch (e) { - $("alerts-body").innerHTML = `

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

`; + $("alerts-body").innerHTML = `

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

`; } } @@ -386,7 +420,7 @@ async function loadSettings() { $("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); + $("settings-msg").textContent = "Failed to load: " + explain(e); } } @@ -413,7 +447,7 @@ async function saveSettings() { setTimeout(() => ($("settings-msg").textContent = ""), 2500); loadRealtime().catch(() => {}); } catch (e) { - $("settings-msg").textContent = "Save failed: " + String(e); + $("settings-msg").textContent = explain(e); } } @@ -507,8 +541,12 @@ async function boot() { setEngineDot("bad", "engine offline"); setState("paused"); $("hero-sub").textContent = - "Can't reach houndd. Is the daemon running? (try `cargo run -p houndd`)"; + "Can't reach houndd. Check that the service is up (`systemctl status houndd`). " + + "If it is, you may not be in the `hound` group yet — the installer adds you, " + + "but it only takes effect after you log out and back in."; } } +// Tells the watchdog in index.html that the script actually ran. +window.__houndBooted = true; boot(); diff --git a/gui/dist/index.html b/gui/dist/index.html index f0cc9fc..9c9f788 100644 --- a/gui/dist/index.html +++ b/gui/dist/index.html @@ -90,7 +90,7 @@
-

ClamAV is working the queue. This can take a while on large folders.

+

Working through the queue. This can take a while on large folders.

@@ -276,10 +276,28 @@
houndd — - engine: ClamAV via Unix socket + engine —
+ diff --git a/gui/package-lock.json b/gui/package-lock.json index 0154fcd..06cf9b5 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -115,9 +115,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -135,9 +132,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -155,9 +149,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -175,9 +166,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -195,9 +183,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 8a40cd4..1285e80 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ serde_json = "1" anyhow = "1" [build-dependencies] -tauri-build = "2" +tauri-build = { version = "2", features = [] } [profile.release] strip = true diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 18df6ae..55db2c7 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -5,6 +5,13 @@ //! //! protected (green) / scanning (amber) / threat (red) / paused (gray) //! +//! `withGlobalTauri` is on in tauri.conf.json and load-bearing: the front-end +//! is plain ES modules with no bundler, so a bare specifier such as +//! "@tauri-apps/api/core" cannot resolve in the webview. It does not error +//! loudly — the script silently fails to load and the window renders its +//! static HTML forever, which is indistinguishable from a daemon that never +//! answered. The API comes off `window.__TAURI__` instead. +//! //! 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 @@ -16,7 +23,6 @@ use hound_api::{ }; 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}; @@ -276,41 +282,29 @@ fn notify(app: &tauri::AppHandle, ev: &Event) { // ── Icon resolution ───────────────────────────────────────────────────────── -fn icon_dir(app: &tauri::AppHandle) -> PathBuf { - // Packaged: resources dir. Dev: the repo's assets/icons. - if let Ok(dir) = app.path().resource_dir() { - let d = dir.join("icons"); - if d.join("state-protected-22.png").exists() { - return d; - } - } - for candidate in [ - PathBuf::from("icons"), - PathBuf::from("../src-tauri/icons"), - PathBuf::from("../assets/icons"), - ] { - if candidate.join("state-protected-22.png").exists() { - return candidate; - } - } - PathBuf::from("icons") -} +// Tray icons are compiled in rather than read from disk. They were being +// loaded from a relative "icons/" path, which resolves only when the binary +// runs from its build directory — installed to /usr/bin and launched from the +// applications menu, the setup hook failed and the whole app panicked before +// a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB +// binary, and it makes the tray icon unable to be missing. +const ICON_BYTES: [(&str, &[u8]); 4] = [ + ("protected", include_bytes!("../icons/state-protected-22.png")), + ("scanning", include_bytes!("../icons/state-scanning-22.png")), + ("threat", include_bytes!("../icons/state-threat-22.png")), + ("paused", include_bytes!("../icons/state-paused-22.png")), +]; -fn load_state_icons(app: &tauri::AppHandle) -> R>> { - let dir = icon_dir(app); +fn load_state_icons() -> R>> { let mut icons = HashMap::new(); - for state in STATES { - let path = dir.join(format!("state-{state}-22.png")); - icons.insert(state.to_string(), load_icon(&path)?); + for (state, bytes) in ICON_BYTES { + let img = Image::from_bytes(bytes) + .map_err(|e| anyhow::anyhow!("decoding the {state} tray icon: {e}"))?; + icons.insert(state.to_string(), img); } Ok(icons) } -/// Load a PNG tray icon; Tauri decodes and converts to RGBA for us. -fn load_icon(path: &Path) -> R> { - Image::from_path(path).map_err(|e| anyhow::anyhow!("loading tray icon {}: {e}", path.display())) -} - // ── App ───────────────────────────────────────────────────────────────────── pub fn run() { @@ -337,7 +331,7 @@ pub fn run() { ]) .setup(|app| { let handle = app.handle().clone(); - let icons = TrayIcons(load_state_icons(&handle)?); + let icons = TrayIcons(load_state_icons()?); let initial_icon = icons .0 .get("protected") diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 46c4190..7b1b25e 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "" }, "app": { + "withGlobalTauri": true, "windows": [ { "title": "Hound Antivirus", diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 171875d..39a4714 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -22,9 +22,39 @@ chmod 0755 "$STAGE" echo "building hound ${VERSION} (${ARCH})" ( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp ) +# The desktop app. Optional: a build host without the webkit/gtk +# development libraries still produces a working CLI package, it just +# does not ship a launcher — which is better than shipping a menu entry +# for a binary that is not there. +GUI_BIN="$ROOT/gui/src-tauri/target/release/hound-gui" +if command -v npx >/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then + # NOT silenced, and NOT tolerant of failure. Discarding this output once + # meant a config error scrolled past unseen and the package shipped the + # previous build's binary — the fix looked like it had no effect, twice. + ( cd "$ROOT/gui" && npm install --no-audit --no-fund >/dev/null \ + && npx tauri build --no-bundle ) +fi +# The webview loads dist/*.js directly, with no bundler. A bare module +# specifier there does not error loudly — it silently fails to resolve and the +# window renders its static HTML forever. Catch it here instead of in a bug +# report. +if grep -rnE '^\s*import .* from "[^./]' "$ROOT/gui/dist"/*.js 2>/dev/null; then + echo "ERROR: bare module specifier in the front-end; the webview cannot resolve it" >&2 + exit 1 +fi + +if [ -x "$GUI_BIN" ]; then + HAVE_GUI=yes + echo " including the desktop app" +else + HAVE_GUI=no + echo " NOTE: no GUI binary — packaging the CLI only, and no launcher" +fi + install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd" install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound" install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp" +[ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui" install -Dm644 "$ROOT/packaging/systemd/houndd.service" \ "$STAGE/lib/systemd/system/houndd.service" install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \ @@ -42,21 +72,32 @@ done install -Dm644 "$ROOT/assets/icons/hound-app.svg" \ "$STAGE/usr/share/icons/hicolor/scalable/apps/hound.svg" +# A menu entry is a promise that clicking it opens something. It ships +# only when the desktop app does, and it launches THAT rather than the +# CLI — Exec=hound with Terminal=true opened a terminal, printed help and +# exited, which reads to anyone sane as "it does not launch". +if [ "$HAVE_GUI" = yes ]; then install -Dm644 /dev/stdin "$STAGE/usr/share/applications/hound.desktop" <<'DESKTOP' [Desktop Entry] Type=Application Name=Hound Antivirus GenericName=Antivirus Comment=Endpoint and supply-chain protection for Linux -Exec=hound +Exec=hound-gui Icon=hound -Categories=System;Security;Utility; +Categories=System;Security; Keywords=antivirus;malware;security;scan;supply chain; -Terminal=true +Terminal=false +StartupWMClass=hound-gui +StartupNotify=true DESKTOP +fi mkdir -p "$STAGE/DEBIAN" +GUI_DEPENDS="" +[ "$HAVE_GUI" = yes ] && GUI_DEPENDS=", libwebkit2gtk-4.1-0, libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1" + cat > "$STAGE/DEBIAN/control" < -Depends: libc6 (>= 2.34) +Depends: libc6 (>= 2.34)${GUI_DEPENDS} Suggests: clamav-daemon Homepage: https://houndav.com Description: Hound Antivirus for Linux @@ -112,6 +153,29 @@ set -e case "$1" in configure) + # The desktop app runs as the logged-in user; the daemon runs as root. + # `hound` is how they meet. Membership grants the read side of the API + # only — status, scan results, the event log — because quarantine writes + # files back out as root and that is not something a group should confer. + if ! getent group hound >/dev/null 2>&1; then + addgroup --system hound >/dev/null 2>&1 || groupadd -r hound >/dev/null 2>&1 || true + fi + + # Enrol whoever ran the install, since on a desktop that is the person who + # will open the app. Group membership only takes effect on their next + # login, which is why the notice below says so out loud. + ADMIN="${SUDO_USER:-${PKEXEC_UID:-}}" + case "$ADMIN" in + ''|root) ADMIN="" ;; + [0-9]*) ADMIN="$(getent passwd "$ADMIN" | cut -d: -f1)" ;; + esac + if [ -n "$ADMIN" ] && getent group hound >/dev/null 2>&1; then + if ! id -nG "$ADMIN" 2>/dev/null | tr ' ' '\n' | grep -qx hound; then + adduser "$ADMIN" hound >/dev/null 2>&1 || usermod -aG hound "$ADMIN" >/dev/null 2>&1 || true + ADDED_TO_GROUP=yes + fi + fi + # The vault holds live malware: root-only, and on a filesystem where # nothing in it can be executed even by accident. mkdir -p /var/lib/hound/vault /var/lib/hound/rules /var/log/hound @@ -124,6 +188,15 @@ case "$1" in # daemon compile them twice and log a duplicate-declaration error on # every start. The copy under /usr/share is documentation, not input. + # Menus cache icons; without this the entry can appear blank until the + # user logs out, which is indistinguishable from a broken package. + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -qtf /usr/share/icons/hicolor 2>/dev/null || true + fi + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications 2>/dev/null || true + fi + if [ -d /run/systemd/system ]; then systemctl daemon-reload || true systemctl enable houndd.service || true @@ -133,6 +206,11 @@ case "$1" in echo "" echo "Hound is installed and scanning on demand." echo "" + if [ "${ADDED_TO_GROUP:-no}" = yes ]; then + echo "Added $ADMIN to the 'hound' group so the desktop app can talk to" + echo "the daemon. Log out and back in for that to take effect." + echo "" + fi echo " hound status what the daemon sees" echo " hound scan ~/Downloads scan a directory" echo "" @@ -174,6 +252,11 @@ case "$1" in # evidence somebody still needs. echo "Removing the Hound quarantine vault at /var/lib/hound/vault" rm -rf /var/lib/hound /var/log/hound + # Leave the group behind if anyone is still in it — removing it would + # silently strip a gid that could be referenced elsewhere on the system. + if getent group hound >/dev/null 2>&1 && [ -z "$(getent group hound | cut -d: -f4)" ]; then + delgroup --system hound >/dev/null 2>&1 || groupdel hound >/dev/null 2>&1 || true + fi ;; esac if [ -d /run/systemd/system ]; then diff --git a/packaging/systemd/houndd.service b/packaging/systemd/houndd.service index 5d1f7e9..dc987ca 100644 --- a/packaging/systemd/houndd.service +++ b/packaging/systemd/houndd.service @@ -24,7 +24,12 @@ RestartSec=2s # files needs DAC_READ_SEARCH; quarantining out of a directory owned by # someone else needs DAC_OVERRIDE; stripping the execute bit off a file # we do not own needs FOWNER. -CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER +# SETUID/SETGID let the daemon fork a child, become the user who sent a scan +# request, and ask the kernel whether that user could have opened the file — +# which is what stops an unprivileged caller using a root scanner to probe +# files it cannot read. It is not an escalation: DAC_OVERRIDE below already +# grants this process every file on the system. +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID # AmbientCapabilities is deliberately NOT set. Ambient capabilities are # inherited by child processes, and the daemon shells out to freshclam, # rpm and pacman on some paths — none of which should start life holding @@ -45,6 +50,13 @@ ReadWritePaths=/var/lib/hound /var/log/hound StateDirectory=hound LogsDirectory=hound RuntimeDirectory=hound +RuntimeDirectoryMode=0750 +# The daemon stays uid 0 — it has to read files no user can. Its *group* is +# `hound`, which is what lets it open the control socket to the desktop app +# without CAP_CHOWN: a process can already chgrp a file it owns to a group it +# belongs to. Membership of `hound` buys the read side of the API and nothing +# that writes; the daemon checks SO_PEERCRED per method (see peer.rs). +Group=hound PrivateTmp=no # ── Everything else we can shut off ────────────────────────────────── diff --git a/site/index.html b/site/index.html index bb7067d..97f41bb 100644 --- a/site/index.html +++ b/site/index.html @@ -332,7 +332,12 @@ code, .inline { .tier .who { color: var(--faint); font-size: 13.5px; margin-top: 8px; min-height: 42px; } .tier hr { border: 0; border-top: 1px solid var(--line); margin: 22px 0 20px; } .tier ul { list-style: none; margin: 0 0 26px; display: flex; flex-direction: column; gap: 12px; flex: 1 1 auto; } -.tier li { display: grid; grid-template-columns: 20px 1fr; gap: 10px; font-size: 14.5px; color: var(--dim); line-height: 1.5; } +/* Two columns, therefore exactly two children: the tick and ONE box + holding all the text. A bare text node sitting beside a + becomes its own anonymous grid item, lands in the 20px icon column, + and wraps one word per line. */ +.tier li { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; font-size: 14.5px; color: var(--dim); line-height: 1.55; align-items: start; } +.tier li > span { min-width: 0; } .tier li svg { width: 15px; height: 15px; margin-top: 4px; color: var(--ok); } .tier li strong { color: var(--fg); font-weight: 600; } .tier .btn { width: 100%; justify-content: center; padding: 13px 17px; } @@ -606,11 +611,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f

Everyone. No account, no telemetry, no expiry.


    -
  • On-demand scanning, CLI and desktop app
  • -
  • Real-time file protection
  • -
  • Quarantine vault with one-click restore
  • -
  • MCP server for your coding assistant
  • -
  • Rootkit and persistence checks
  • +
  • On-demand scanning, CLI and desktop app
  • +
  • Real-time file protection
  • +
  • Quarantine vault with one-click restore
  • +
  • MCP server for your coding assistant
  • +
  • Rootkit and persistence checks
Install

Apache-2.0. Yours to read and to fork.

@@ -623,11 +628,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f

One machine, for someone who builds software on it.


    -
  • Everything in Free
  • -
  • Execution gate — a malicious binary is refused before it runs, in 2 ms
  • -
  • Supply-chain scanner against all 235,577 indicators
  • -
  • Hound Linux threat pack — miners, backdoors, rootkits, webshells
  • -
  • Signed definitions, updated automatically
  • +
  • Everything in Free
  • +
  • Execution gate — a malicious binary is refused before it runs, in 2 ms
  • +
  • Supply-chain scanner against all 235,577 indicators
  • +
  • Hound Linux threat pack — miners, backdoors, rootkits, webshells
  • +
  • Signed definitions, updated automatically
Get Pro

Annual only. Billed once, cancel any time.

@@ -639,11 +644,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f

Up to 10 seats, then $15 each. Annual at ten months' price.


    -
  • Everything in Pro, on every machine
  • -
  • Central console, enrolment and policy push
  • -
  • Compliance reports an auditor accepts
  • -
  • Air-gapped definition mirrors
  • -
  • Priority support and a named contact
  • +
  • Everything in Pro, on every machine
  • +
  • Central console, enrolment and policy push
  • +
  • Compliance reports an auditor accepts
  • +
  • Air-gapped definition mirrors
  • +
  • Priority support and a named contact
Talk to us

Over 20 seats? We will quote you.