0.1.2: hound update brings the whole machine current

Definitions and the application were two separate acts with two
different mechanisms, and only one of them had a command. `hound
update` now does both:

  sudo hound update                definitions, then offer the app
  sudo hound update -y             both, unattended
  hound update --check             what is available, install nothing
  sudo hound update --definitions-only

Definitions run first and independently: a release check that fails —
laptop offline, host down — is a warning, not a failed command. The
definitions half is what matters between releases and it either worked
or it did not, regardless of what the manifest server did.

The app half prompts unless given --yes, because definitions are
verified before they are parsed while replacing the running binary is a
larger step. Non-interactive callers never block on a read: they are
told to pass --yes and exit cleanly.

`release.check` is classified read-only, so any member of the `hound`
group can ask whether they are current — it changes nothing and reveals
nothing the website does not. Installing still requires root and says
so.

Verified end to end on a real machine: a 0.1.1 install discovered
0.1.2, verified the signed manifest, checked the SHA-256, staged it
root-owned and installed it through apt, unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dev 2026-08-21 12:10:20 -05:00
parent 38d1feeb0c
commit cb943c66c4
13 changed files with 225 additions and 22 deletions

15
Cargo.lock generated
View file

@ -1089,19 +1089,22 @@ dependencies = [
[[package]] [[package]]
name = "hound" name = "hound"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"colored", "colored",
"hound-api", "hound-api",
"hound-supply", "hound-supply",
"libc",
"serde_json", "serde_json",
"sha2",
"ureq",
] ]
[[package]] [[package]]
name = "hound-api" name = "hound-api"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1111,7 +1114,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-defs" name = "hound-defs"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"serde", "serde",
@ -1121,7 +1124,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-mcp" name = "hound-mcp"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"hound-api", "hound-api",
"hound-supply", "hound-supply",
@ -1131,7 +1134,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-supply" name = "hound-supply"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"hound-defs", "hound-defs",
"serde", "serde",
@ -1140,7 +1143,7 @@ dependencies = [
[[package]] [[package]]
name = "houndd" name = "houndd"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ed25519-dalek", "ed25519-dalek",

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.1.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus.git" repository = "https://git.joelovestech.com/Hound/Antivirus.git"

View file

@ -271,7 +271,7 @@ pub struct ScanResult {
} }
/// Result of the `update` method (a freshclam run). /// Result of the `update` method (a freshclam run).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateResult { pub struct UpdateResult {
/// True when freshclam exited 0 (DB actually refreshed or already current). /// True when freshclam exited 0 (DB actually refreshed or already current).
pub ok: bool, pub ok: bool,

View file

@ -11,6 +11,9 @@ name = "hound"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
sha2.workspace = true
libc.workspace = true
ureq.workspace = true
hound-api = { path = "../hound-api" } hound-api = { path = "../hound-api" }
hound-supply.workspace = true hound-supply.workspace = true
anyhow.workspace = true anyhow.workspace = true

View file

@ -11,7 +11,7 @@
//! //!
//! Exit codes: 0 = clean, 1 = infected, 2 = usage/engine error. //! Exit codes: 0 = clean, 1 = infected, 2 = usage/engine error.
use anyhow::Result; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::Colorize; use colored::Colorize;
use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult}; use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult};
@ -68,11 +68,20 @@ enum Cmd {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// Download the latest signed definition packs /// Bring this machine up to date: definitions, then Hound itself
Update { Update {
/// Emit machine-readable JSON instead of human text /// Emit machine-readable JSON instead of human text
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Definitions only; do not install a new version of Hound
#[arg(long)]
definitions_only: bool,
/// Say what is available without installing anything
#[arg(long)]
check: bool,
/// Install a new version without asking
#[arg(long, short = 'y')]
yes: bool,
}, },
/// Show recent alerts (scan / threat / quarantine / ransomware …) /// Show recent alerts (scan / threat / quarantine / ransomware …)
Events { Events {
@ -506,13 +515,89 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
print_human(&r, path); print_human(&r, path);
Ok(if r.is_clean() { 0 } else { 1 }) Ok(if r.is_clean() { 0 } else { 1 })
} }
Cmd::Update { json } => { Cmd::Update {
let u: UpdateResult = client.update()?; json,
definitions_only,
check,
yes,
} => {
// Definitions first. They are the half that can always be done
// and the half that matters most between releases.
let u: UpdateResult = if *check {
UpdateResult::default()
} else {
client.update()?
};
let app = if *definitions_only {
None
} else {
// A check that fails is not a reason to call the whole
// command a failure — the definitions half may well have
// worked, and a laptop is offline all the time.
match client.raw_call("release.check", None) {
Ok(v) => Some(v),
Err(e) => {
if !*json {
eprintln!("{} could not check for a new version: {e}", "!".yellow());
}
None
}
}
};
if *json { if *json {
println!("{}", serde_json::to_string_pretty(&u)?); println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"definitions": u,
"app": app,
}))?
);
return Ok(if u.ok { 0 } else { 1 }); return Ok(if u.ok { 0 } else { 1 });
} }
if !*check {
print_update_human(&u); print_update_human(&u);
}
let available = app
.as_ref()
.and_then(|v| v.get("update_available"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !available {
if let Some(v) = &app {
let cur = v.get("current").and_then(|c| c.as_str()).unwrap_or("?");
println!("{} Hound {cur} is the latest version", "".green().bold());
}
return Ok(if u.ok { 0 } else { 1 });
}
let v = app.as_ref().expect("available implies a response");
let latest = v.get("latest").and_then(|c| c.as_str()).unwrap_or_default();
let deb_url = v.get("deb_url").and_then(|c| c.as_str()).unwrap_or_default();
let sha = v.get("deb_sha256").and_then(|c| c.as_str()).unwrap_or_default();
println!("{} Hound {latest} is available", "".cyan().bold());
if *check {
if let Some(notes) = v.get("notes_url").and_then(|c| c.as_str()) {
if !notes.is_empty() {
println!(" what changed: {}", notes.dimmed());
}
}
println!(" install it with: {}", "sudo hound update".yellow());
return Ok(0);
}
match install_app_update(latest, deb_url, sha, *yes) {
Ok(true) => println!("{} Hound {latest} installed", "".green().bold()),
Ok(false) => {}
Err(e) => {
eprintln!("{} {e}", "".red().bold());
return Ok(1);
}
}
Ok(if u.ok { 0 } else { 1 }) Ok(if u.ok { 0 } else { 1 })
} }
Cmd::Events { limit, json } => { Cmd::Events { limit, json } => {
@ -746,6 +831,85 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
} }
} }
/// Download, verify and install a published release.
///
/// Hound does not replace its own binary — a root process that rewrites
/// itself is the mechanism a supply-chain attacker most wants. The install
/// goes through the system package manager, which is the same path a person
/// would take by hand.
///
/// Returns Ok(false) when the user declined, which is not a failure.
fn install_app_update(version: &str, deb_url: &str, sha256: &str, assume_yes: bool) -> Result<bool> {
use sha2::{Digest, Sha256};
use std::io::{IsTerminal, Read as _, Write as _};
if unsafe { libc::geteuid() } != 0 {
// The definitions half already ran, so say what worked before saying
// what did not.
anyhow::bail!("installing Hound {version} needs root — run: sudo hound update");
}
// A signed manifest establishes what the publisher intended and nothing
// more. It does not stop a publisher's mistake pointing elsewhere.
if !deb_url.starts_with("https://dl.houndav.com/") {
anyhow::bail!("the published update points somewhere unexpected; not downloading it");
}
if !assume_yes {
if !std::io::stdin().is_terminal() {
println!(" run with --yes to install it without a prompt");
return Ok(false);
}
print!(" install Hound {version} now? [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
println!(" left it for later");
return Ok(false);
}
}
println!(" downloading {version}");
let resp = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(300))
.build()
.get(deb_url)
.call()
.with_context(|| format!("downloading {deb_url}"))?;
let mut body = Vec::new();
// Generous for a package, and far short of filling a disk.
resp.into_reader()
.take(256 << 20)
.read_to_end(&mut body)
.context("reading the download")?;
let got: String = Sha256::digest(&body).iter().map(|b| format!("{b:02x}")).collect();
if !sha256.is_empty() && got != sha256 {
anyhow::bail!("the download does not match its signed checksum; discarded");
}
// Root-owned staging, so nothing can substitute the file between the
// check above and the package manager reading it.
const STAGE_DIR: &str = "/var/lib/hound/updates";
std::fs::create_dir_all(STAGE_DIR)?;
std::fs::set_permissions(STAGE_DIR, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
let path = std::path::Path::new(STAGE_DIR).join(format!("hound_{version}_amd64.deb"));
std::fs::write(&path, &body)?;
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
let status = std::process::Command::new("apt-get")
.args(["install", "-y"])
.arg(&path)
.status()
.context("running apt-get")?;
if !status.success() {
anyhow::bail!("the package manager refused the update (staged at {})", path.display());
}
let _ = std::fs::remove_file(&path);
Ok(true)
}
fn print_update_human(u: &UpdateResult) { fn print_update_human(u: &UpdateResult) {
if u.ok { if u.ok {
println!( println!(
@ -944,6 +1108,10 @@ mod tests {
vec!["hound", "status"], vec!["hound", "status"],
vec!["hound", "scan", "/tmp"], vec!["hound", "scan", "/tmp"],
vec!["hound", "update"], vec!["hound", "update"],
vec!["hound", "update", "--check"],
vec!["hound", "update", "--yes"],
vec!["hound", "update", "-y"],
vec!["hound", "update", "--definitions-only"],
vec!["hound", "settings", "show"], vec!["hound", "settings", "show"],
vec!["hound", "settings", "exec-gate", "on"], vec!["hound", "settings", "exec-gate", "on"],
vec!["hound", "settings", "exec-gate", "off"], vec!["hound", "settings", "exec-gate", "off"],

View file

@ -532,6 +532,29 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
match req.method.as_str() { match req.method.as_str() {
"status" => Ok(serde_json::to_value(status(st)?)?), "status" => Ok(serde_json::to_value(status(st)?)?),
"update" => Ok(serde_json::to_value(update(st)?)?), "update" => Ok(serde_json::to_value(update(st)?)?),
// A fresh, verified look at the release manifest. The scheduler
// checks daily; someone who typed `hound update` is asking now, and
// "your daily check has not run yet" is not an answer.
"release.check" => {
let keys = defs::trusted_keys();
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
let found = release::fetch(&release::base_url(), &trusted)?;
if let Some(rel) = found.clone() {
*KNOWN_RELEASE.lock().expect("release lock poisoned") = Some(rel);
}
let newer = found
.as_ref()
.filter(|r| release::is_newer(&r.version, DAEMON_VERSION));
Ok(serde_json::json!({
"current": DAEMON_VERSION,
"latest": found.as_ref().map(|r| r.version.clone()).unwrap_or_default(),
"update_available": newer.is_some(),
"deb_url": newer.map(|r| r.deb_url.clone()).unwrap_or_default(),
"deb_sha256": found.as_ref().map(|r| r.deb_sha256.clone()).unwrap_or_default(),
"notes_url": found.as_ref().map(|r| r.notes_url.clone()).unwrap_or_default(),
}))
}
"scan" => { "scan" => {
let path = req let path = req
.params .params

View file

@ -106,7 +106,10 @@ pub fn access_for(method: &str) -> Access {
| "quarantine.list" | "quarantine.list"
| "realtime.status" | "realtime.status"
| "rootkit.scan" | "rootkit.scan"
| "persistence.scan" => Access::Read, | "persistence.scan"
// Asking whether a newer version exists changes nothing, and reveals
// nothing the website does not already say.
| "release.check" => Access::Read,
"scan" | "supply.sweep" => Access::ReadsPath, "scan" | "supply.sweep" => Access::ReadsPath,
@ -253,6 +256,9 @@ mod tests {
fn reads_are_open_and_writes_are_not() { fn reads_are_open_and_writes_are_not() {
assert_eq!(access_for("status"), Access::Read); assert_eq!(access_for("status"), Access::Read);
assert_eq!(access_for("quarantine.list"), Access::Read); assert_eq!(access_for("quarantine.list"), Access::Read);
// Asking whether a newer version exists changes nothing and reveals
// nothing the website does not.
assert_eq!(access_for("release.check"), Access::Read);
assert_eq!(access_for("scan"), Access::ReadsPath); assert_eq!(access_for("scan"), Access::ReadsPath);
assert_eq!(access_for("supply.sweep"), Access::ReadsPath); assert_eq!(access_for("supply.sweep"), Access::ReadsPath);
for admin in [ for admin in [

BIN
dist/hound_0.1.2_amd64.deb vendored Normal file

Binary file not shown.

4
gui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-dialog": "^2.7.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.1", "version": "0.1.2",
"description": "Hound Antivirus — desktop app", "description": "Hound Antivirus — desktop app",
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hound-api" name = "hound-api"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-gui" name = "hound-gui"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hound-api", "hound-api",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "hound-gui" name = "hound-gui"
description = "Hound Antivirus desktop app (Tauri 2)" description = "Hound Antivirus desktop app (Tauri 2)"
version = "0.1.1" version = "0.1.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus" repository = "https://git.joelovestech.com/Hound/Antivirus"

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Hound Antivirus", "productName": "Hound Antivirus",
"version": "0.1.1", "version": "0.1.2",
"identifier": "com.joelovestech.hound", "identifier": "com.joelovestech.hound",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",