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:
parent
38d1feeb0c
commit
cb943c66c4
13 changed files with 225 additions and 22 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -1089,19 +1089,22 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"colored",
|
||||
"hound-api",
|
||||
"hound-supply",
|
||||
"libc",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hound-api"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
|
@ -1111,7 +1114,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hound-defs"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"serde",
|
||||
|
|
@ -1121,7 +1124,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hound-mcp"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"hound-api",
|
||||
"hound-supply",
|
||||
|
|
@ -1131,7 +1134,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hound-supply"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"hound-defs",
|
||||
"serde",
|
||||
|
|
@ -1140,7 +1143,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "houndd"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ed25519-dalek",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ resolver = "2"
|
|||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://git.joelovestech.com/Hound/Antivirus.git"
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ pub struct ScanResult {
|
|||
}
|
||||
|
||||
/// Result of the `update` method (a freshclam run).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateResult {
|
||||
/// True when freshclam exited 0 (DB actually refreshed or already current).
|
||||
pub ok: bool,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ name = "hound"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
sha2.workspace = true
|
||||
libc.workspace = true
|
||||
ureq.workspace = true
|
||||
hound-api = { path = "../hound-api" }
|
||||
hound-supply.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
//!
|
||||
//! Exit codes: 0 = clean, 1 = infected, 2 = usage/engine error.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult};
|
||||
|
|
@ -68,11 +68,20 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Download the latest signed definition packs
|
||||
/// Bring this machine up to date: definitions, then Hound itself
|
||||
Update {
|
||||
/// Emit machine-readable JSON instead of human text
|
||||
#[arg(long)]
|
||||
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 …)
|
||||
Events {
|
||||
|
|
@ -506,13 +515,89 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
|||
print_human(&r, path);
|
||||
Ok(if r.is_clean() { 0 } else { 1 })
|
||||
}
|
||||
Cmd::Update { json } => {
|
||||
let u: UpdateResult = client.update()?;
|
||||
Cmd::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 {
|
||||
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 });
|
||||
}
|
||||
|
||||
if !*check {
|
||||
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 })
|
||||
}
|
||||
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) {
|
||||
if u.ok {
|
||||
println!(
|
||||
|
|
@ -944,6 +1108,10 @@ mod tests {
|
|||
vec!["hound", "status"],
|
||||
vec!["hound", "scan", "/tmp"],
|
||||
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", "exec-gate", "on"],
|
||||
vec!["hound", "settings", "exec-gate", "off"],
|
||||
|
|
|
|||
|
|
@ -532,6 +532,29 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
|||
match req.method.as_str() {
|
||||
"status" => Ok(serde_json::to_value(status(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" => {
|
||||
let path = req
|
||||
.params
|
||||
|
|
|
|||
|
|
@ -106,7 +106,10 @@ pub fn access_for(method: &str) -> Access {
|
|||
| "quarantine.list"
|
||||
| "realtime.status"
|
||||
| "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,
|
||||
|
||||
|
|
@ -253,6 +256,9 @@ mod tests {
|
|||
fn reads_are_open_and_writes_are_not() {
|
||||
assert_eq!(access_for("status"), 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("supply.sweep"), Access::ReadsPath);
|
||||
for admin in [
|
||||
|
|
|
|||
BIN
dist/hound_0.1.2_amd64.deb
vendored
Normal file
BIN
dist/hound_0.1.2_amd64.deb
vendored
Normal file
Binary file not shown.
4
gui/package-lock.json
generated
4
gui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Hound Antivirus — desktop app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
4
gui/src-tauri/Cargo.lock
generated
4
gui/src-tauri/Cargo.lock
generated
|
|
@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||
|
||||
[[package]]
|
||||
name = "hound-api"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
|
@ -1477,7 +1477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hound-gui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hound-api",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "hound-gui"
|
||||
description = "Hound Antivirus desktop app (Tauri 2)"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://git.joelovestech.com/Hound/Antivirus"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hound Antivirus",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"identifier": "com.joelovestech.hound",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
|
|
|||
Loading…
Reference in a new issue