0.1.1: signed release notifications, and definitions that actually update
Two mechanisms Joe asked for, neither of which existed. **Definitions now update themselves.** `auto_update_signatures` shipped from the start and nothing ever read it — definitions only moved when somebody typed `hound update`, which for a security product means most installations were running whatever they were installed with. There is now a scheduler thread: definitions hourly, release check daily, and a 90-second startup delay so a fleet that reboots together does not arrive at the CDN in one wave. It calls the same install path as `hound update` rather than a second implementation that could drift. **Release notifications, signed.** dl.houndav.com/latest.json carries an Ed25519 signature from the same key as the definition packs, for the same reason: whoever serves that host must not be able to invent a version and point our users at a binary of their choosing. A manifest that does not verify is discarded, and no manifest means "nothing newer known" — never "update available". The safe default is silence. The signature covers a canonical byte form defined identically in release.rs and tools/publish-release.py. Signing a re-serialisation of a parsed struct is the classic way to verify one thing and act on another, so a test runs a real manifest produced by the Python tool through the Rust verifier against the production key. If those drift, manifests verify nowhere and the only symptom is that nobody ever hears about an update. Version comparison is numeric, not lexical, because "0.9.0" > "0.10.0" as strings and that is precisely the pair where it would first be noticed. Downgrades are never offered — a signed-but-old manifest must not become a way to reintroduce a fixed vulnerability. The app does not replace its own binary. A root daemon that can rewrite itself is the mechanism a supply-chain attacker most wants. Installing goes through apt with the user present and authenticating: the package is downloaded, checked against the manifest's SHA-256, staged into a root-owned 0700 directory so nothing can substitute it between the check and the read, and handed over. Two guards beyond the signature — the URL must be on our own download host, because a signature proves publisher intent and not that the publisher got the URL right. Tray, per Joe's design: a new amber "attention" state ranked below a threat and an in-flight scan, above protected. Deeper amber than "scanning" so the two are distinguishable — scanning lasts seconds and the user started it, attention persists. The tooltip carries the reason rather than only that there is one, and "Install Hound X…" appears enabled only when there is something to install; a permanently greyed item teaches people the menu is decorative. Definition staleness is a first-class signal, not just update availability: amber at 7 days, and at 30 the wording stops pretending — "this machine is not currently protected against anything found since then". An antivirus showing green on month-old definitions is lying in the same way a dead front-end showing "Protected" was. Also: the four files that declare a version now have a test asserting they agree. Drift there means a release looks older than what is installed and the update silently never offers, or offers forever. 377 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a3f31288fa
commit
38d1feeb0c
22 changed files with 1092 additions and 17 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1089,7 +1089,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound"
|
name = "hound"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
@ -1101,7 +1101,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-api"
|
name = "hound-api"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1111,7 +1111,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-defs"
|
name = "hound-defs"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1121,7 +1121,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-mcp"
|
name = "hound-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hound-api",
|
"hound-api",
|
||||||
"hound-supply",
|
"hound-supply",
|
||||||
|
|
@ -1131,7 +1131,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-supply"
|
name = "hound-supply"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hound-defs",
|
"hound-defs",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1140,7 +1140,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "houndd"
|
name = "houndd"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ resolver = "2"
|
||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.joelovestech.com/Hound/Antivirus.git"
|
repository = "https://git.joelovestech.com/Hound/Antivirus.git"
|
||||||
|
|
|
||||||
BIN
assets/icons/state-attention-16.png
Normal file
BIN
assets/icons/state-attention-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 627 B |
BIN
assets/icons/state-attention-22.png
Normal file
BIN
assets/icons/state-attention-22.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
BIN
assets/icons/state-attention-24.png
Normal file
BIN
assets/icons/state-attention-24.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 936 B |
BIN
assets/icons/state-attention-32.png
Normal file
BIN
assets/icons/state-attention-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/state-attention-48.png
Normal file
BIN
assets/icons/state-attention-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -108,6 +108,11 @@ pub struct Status {
|
||||||
/// Execution-gate state.
|
/// Execution-gate state.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gate: GateStatus,
|
pub gate: GateStatus,
|
||||||
|
/// What the daemon knows about staying current: whether a newer release
|
||||||
|
/// has been published, and how old the definitions are. Clients turn
|
||||||
|
/// this into a tray colour rather than deriving it from prose.
|
||||||
|
#[serde(default)]
|
||||||
|
pub freshness: Freshness,
|
||||||
/// Loaded definition packs.
|
/// Loaded definition packs.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub defs: DefsStatus,
|
pub defs: DefsStatus,
|
||||||
|
|
@ -173,6 +178,52 @@ pub struct DefsStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execution-gate state, for the tray and `hound status`.
|
/// Execution-gate state, for the tray and `hound status`.
|
||||||
|
/// Whether this installation is current, and how loudly to say so.
|
||||||
|
///
|
||||||
|
/// The daemon decides this, not the client — a tray icon and a CLI must not
|
||||||
|
/// disagree about whether the machine is protected, and the rule for "stale"
|
||||||
|
/// belongs in one place.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
|
pub struct Freshness {
|
||||||
|
/// Version of the newest published release, if a signed manifest said so
|
||||||
|
/// and it is newer than what is running. Empty means "nothing newer
|
||||||
|
/// known" — which is also what a missing or unverifiable manifest means,
|
||||||
|
/// because the safe default is silence, not a false alarm.
|
||||||
|
#[serde(default)]
|
||||||
|
pub update_version: String,
|
||||||
|
/// Where to read what changed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub update_notes_url: String,
|
||||||
|
/// Where the package lives, for the assisted install.
|
||||||
|
#[serde(default)]
|
||||||
|
pub update_deb_url: String,
|
||||||
|
/// SHA-256 of that package, checked before it is handed to the system
|
||||||
|
/// package manager.
|
||||||
|
#[serde(default)]
|
||||||
|
pub update_deb_sha256: String,
|
||||||
|
/// Whole days since the definitions were published.
|
||||||
|
#[serde(default)]
|
||||||
|
pub defs_age_days: u32,
|
||||||
|
/// "ok" | "stale" | "very_stale" — the daemon's own verdict on that age.
|
||||||
|
#[serde(default)]
|
||||||
|
pub defs_state: String,
|
||||||
|
/// One line a person can read, already phrased. Empty when all is well.
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Definitions older than this are worth an amber icon.
|
||||||
|
pub const DEFS_STALE_DAYS: u32 = 7;
|
||||||
|
/// Older than this and "protected" is no longer an honest thing to display.
|
||||||
|
pub const DEFS_VERY_STALE_DAYS: u32 = 30;
|
||||||
|
|
||||||
|
impl Freshness {
|
||||||
|
/// True when the tray should be amber: something needs the user.
|
||||||
|
pub fn wants_attention(&self) -> bool {
|
||||||
|
!self.update_version.is_empty() || self.defs_state != "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct GateStatus {
|
pub struct GateStatus {
|
||||||
/// True only when fanotify is armed and answering.
|
/// True only when fanotify is armed and answering.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,15 @@ enum Cmd {
|
||||||
/// privilege.
|
/// privilege.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
AdminRpc,
|
AdminRpc,
|
||||||
|
/// Write stdin to a staged update file (internal).
|
||||||
|
///
|
||||||
|
/// The desktop app downloads a published package and checks it against
|
||||||
|
/// the signed manifest, then needs it somewhere root-owned so nothing can
|
||||||
|
/// substitute the file between that check and the package manager reading
|
||||||
|
/// it. Deliberately narrow: one fixed directory, one filename shape, and
|
||||||
|
/// no way to name a path outside it.
|
||||||
|
#[command(hide = true)]
|
||||||
|
StageUpdate { path: String },
|
||||||
/// Scan a file or directory
|
/// Scan a file or directory
|
||||||
Scan {
|
Scan {
|
||||||
/// Path to scan (file or directory)
|
/// Path to scan (file or directory)
|
||||||
|
|
@ -401,6 +410,39 @@ fn client(sock: &Option<String>) -> Result<Client> {
|
||||||
/// Returns the process exit code.
|
/// Returns the process exit code.
|
||||||
fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||||
match cmd {
|
match cmd {
|
||||||
|
Cmd::StageUpdate { path } => {
|
||||||
|
use std::io::Read as _;
|
||||||
|
const STAGE_DIR: &str = "/var/lib/hound/updates";
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
// The caller is unprivileged and we are not; treat the path as
|
||||||
|
// hostile. It must sit directly in the staging directory and be
|
||||||
|
// named like a package — no traversal, no subdirectories, no
|
||||||
|
// writing over anything else on the system.
|
||||||
|
let name = p
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no filename"))?;
|
||||||
|
if p.parent() != Some(std::path::Path::new(STAGE_DIR))
|
||||||
|
|| !name.ends_with(".deb")
|
||||||
|
|| name.contains('/')
|
||||||
|
|| name.starts_with('.')
|
||||||
|
{
|
||||||
|
anyhow::bail!("a staged update must be a .deb directly in {STAGE_DIR}");
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(STAGE_DIR)?;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
STAGE_DIR,
|
||||||
|
std::os::unix::fs::PermissionsExt::from_mode(0o700),
|
||||||
|
)?;
|
||||||
|
let mut body = Vec::new();
|
||||||
|
// A package is tens of megabytes; this bound is generous and
|
||||||
|
// still cannot fill a disk.
|
||||||
|
std::io::stdin().take(256 << 20).read_to_end(&mut body)?;
|
||||||
|
std::fs::write(p, &body)?;
|
||||||
|
std::fs::set_permissions(p, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||||
|
println!("staged {} bytes", body.len());
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
Cmd::AdminRpc => {
|
Cmd::AdminRpc => {
|
||||||
use std::io::Read as _;
|
use std::io::Read as _;
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ const TRUSTED_KEYS: &[(&str, [u8; 32])] = &[(
|
||||||
],
|
],
|
||||||
)];
|
)];
|
||||||
|
|
||||||
|
/// The production release key, for tests that must check against the real
|
||||||
|
/// one rather than a key the test made up.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn production_key() -> [u8; 32] {
|
||||||
|
TRUSTED_KEYS[0].1
|
||||||
|
}
|
||||||
|
|
||||||
/// Where signed packs live.
|
/// Where signed packs live.
|
||||||
pub fn defs_dir() -> Option<PathBuf> {
|
pub fn defs_dir() -> Option<PathBuf> {
|
||||||
if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") {
|
if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ mod peer;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod quarantine;
|
mod quarantine;
|
||||||
mod realtime;
|
mod realtime;
|
||||||
|
mod release;
|
||||||
mod rootkit;
|
mod rootkit;
|
||||||
mod rules;
|
mod rules;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
@ -106,6 +107,7 @@ fn main() -> Result<()> {
|
||||||
open_socket_to_hound_group(&sock_path);
|
open_socket_to_hound_group(&sock_path);
|
||||||
|
|
||||||
let state = DaemonState::boot();
|
let state = DaemonState::boot();
|
||||||
|
start_scheduler(state.clone());
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)",
|
"houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)",
|
||||||
|
|
@ -741,6 +743,160 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
||||||
|
|
||||||
// ── RPC handlers (engine-agnostic) ──────────────────────────────────────────
|
// ── RPC handlers (engine-agnostic) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One definitions check, using the same install path as `hound update` so
|
||||||
|
/// there is no second implementation to drift. Returns how many packs landed.
|
||||||
|
fn scheduled_defs_update(st: &DaemonState) -> Result<usize> {
|
||||||
|
let keys = defs::trusted_keys();
|
||||||
|
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
|
||||||
|
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
|
||||||
|
let outcome = update::run(&update::install_dir(), &trusted)?;
|
||||||
|
if !outcome.installed.is_empty() {
|
||||||
|
st.defs.reload();
|
||||||
|
}
|
||||||
|
Ok(outcome.installed.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often to look for new definitions, and for a new release.
|
||||||
|
///
|
||||||
|
/// Definitions on the hour: the feed is rebuilt daily, and an hourly check
|
||||||
|
/// costs one small HTTP request against a cached index. Releases daily,
|
||||||
|
/// because a release the user cannot install without authenticating is not
|
||||||
|
/// something to nag about.
|
||||||
|
const DEFS_CHECK_SECS: u64 = 60 * 60;
|
||||||
|
const RELEASE_CHECK_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Wait before the first check, so a machine that has just booted is not
|
||||||
|
/// racing the network stack, and so a fleet that reboots together does not
|
||||||
|
/// arrive at the CDN in one wave.
|
||||||
|
const FIRST_CHECK_DELAY_SECS: u64 = 90;
|
||||||
|
|
||||||
|
/// Keep the definitions current and notice when a new Hound is published.
|
||||||
|
///
|
||||||
|
/// This is what `auto_update_signatures` has always claimed to do. The
|
||||||
|
/// setting shipped from the start and nothing read it: definitions only
|
||||||
|
/// updated when somebody typed `hound update`, which for a security product
|
||||||
|
/// means most installations were quietly running whatever they were
|
||||||
|
/// installed with.
|
||||||
|
fn start_scheduler(st: DaemonState) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(FIRST_CHECK_DELAY_SECS));
|
||||||
|
let mut since_release_check = RELEASE_CHECK_SECS; // check once at startup
|
||||||
|
loop {
|
||||||
|
if st.settings.get().auto_update_signatures {
|
||||||
|
match scheduled_defs_update(&st) {
|
||||||
|
Ok(n) if n > 0 => {
|
||||||
|
st.events.push(
|
||||||
|
"update",
|
||||||
|
"info",
|
||||||
|
format!("installed {n} new definition pack(s)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
// A failed check is not an event worth waking a user for
|
||||||
|
// — laptops are offline all the time. It becomes visible
|
||||||
|
// through the definitions ageing, which is the thing that
|
||||||
|
// actually matters.
|
||||||
|
Err(e) => eprintln!("scheduler: definitions check failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
since_release_check += DEFS_CHECK_SECS;
|
||||||
|
if since_release_check >= RELEASE_CHECK_SECS {
|
||||||
|
since_release_check = 0;
|
||||||
|
let keys = defs::trusted_keys();
|
||||||
|
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
|
||||||
|
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
|
||||||
|
match release::fetch(&release::base_url(), &trusted) {
|
||||||
|
Ok(Some(rel)) => {
|
||||||
|
if release::is_newer(&rel.version, DAEMON_VERSION) {
|
||||||
|
eprintln!("scheduler: Hound {} is available", rel.version);
|
||||||
|
}
|
||||||
|
*KNOWN_RELEASE.lock().expect("release lock poisoned") = Some(rel);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
// A manifest that does not verify is worth saying out
|
||||||
|
// loud: it means somebody served us something they should
|
||||||
|
// not have been able to.
|
||||||
|
Err(e) => eprintln!("scheduler: release manifest rejected: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(DEFS_CHECK_SECS));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the daemon knows about a newer release, refreshed by the scheduler.
|
||||||
|
/// Empty until a signed manifest has been fetched and verified — the safe
|
||||||
|
/// default is silence, never a false alarm.
|
||||||
|
static KNOWN_RELEASE: std::sync::Mutex<Option<release::Release>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// Whole days between a `YYYY.MM.DD` feed version and today.
|
||||||
|
///
|
||||||
|
/// Returns None rather than 0 when the version cannot be parsed. A zero would
|
||||||
|
/// read as "published today", which is the reassuring answer, and guessing
|
||||||
|
/// reassuringly is how a security product ends up lying.
|
||||||
|
fn defs_age_days(version: &str) -> Option<u32> {
|
||||||
|
let mut parts = version.split(['.', '-']);
|
||||||
|
let y: i32 = parts.next()?.parse().ok()?;
|
||||||
|
let m: u8 = parts.next()?.parse().ok()?;
|
||||||
|
let d: u8 = parts.next()?.parse().ok()?;
|
||||||
|
let published = time::Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok()?;
|
||||||
|
let today = time::OffsetDateTime::now_utc().date();
|
||||||
|
Some((today - published).whole_days().max(0) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn freshness(st: &DaemonState) -> hound_api::Freshness {
|
||||||
|
let mut f = hound_api::Freshness::default();
|
||||||
|
|
||||||
|
let defs = st.defs.current();
|
||||||
|
match defs_age_days(&defs.version) {
|
||||||
|
Some(days) => {
|
||||||
|
f.defs_age_days = days;
|
||||||
|
f.defs_state = if days >= hound_api::DEFS_VERY_STALE_DAYS {
|
||||||
|
"very_stale"
|
||||||
|
} else if days >= hound_api::DEFS_STALE_DAYS {
|
||||||
|
"stale"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
// No definitions at all, or a version we cannot date. Either way this
|
||||||
|
// is not a machine with current protection, and saying "ok" would be
|
||||||
|
// the most misleading answer available.
|
||||||
|
None => {
|
||||||
|
f.defs_state = "very_stale".into();
|
||||||
|
f.defs_age_days = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rel) = KNOWN_RELEASE.lock().ok().and_then(|g| g.clone()) {
|
||||||
|
if release::is_newer(&rel.version, DAEMON_VERSION) {
|
||||||
|
f.update_version = rel.version.clone();
|
||||||
|
f.update_notes_url = rel.notes_url.clone();
|
||||||
|
f.update_deb_url = rel.deb_url.clone();
|
||||||
|
f.update_deb_sha256 = rel.deb_sha256.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.summary = match (f.update_version.is_empty(), f.defs_state.as_str()) {
|
||||||
|
(true, "ok") => String::new(),
|
||||||
|
(false, "ok") => format!("Hound {} is available", f.update_version),
|
||||||
|
(true, "stale") => format!("Definitions are {} days old", f.defs_age_days),
|
||||||
|
(true, _) => format!(
|
||||||
|
"Definitions are {} days old — this machine is not currently protected against \
|
||||||
|
anything found since then",
|
||||||
|
f.defs_age_days
|
||||||
|
),
|
||||||
|
(false, _) => format!(
|
||||||
|
"Hound {} is available, and definitions are {} days old",
|
||||||
|
f.update_version, f.defs_age_days
|
||||||
|
),
|
||||||
|
};
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
fn status(st: &DaemonState) -> Result<hound_api::Status> {
|
fn status(st: &DaemonState) -> Result<hound_api::Status> {
|
||||||
let (present, db_summary, db) = engine::engine().probe();
|
let (present, db_summary, db) = engine::engine().probe();
|
||||||
let os = std::fs::read_to_string("/etc/os-release")
|
let os = std::fs::read_to_string("/etc/os-release")
|
||||||
|
|
@ -763,6 +919,7 @@ fn status(st: &DaemonState) -> Result<hound_api::Status> {
|
||||||
realtime: st.realtime.status(),
|
realtime: st.realtime.status(),
|
||||||
quarantined: st.quarantine.count(),
|
quarantined: st.quarantine.count(),
|
||||||
gate: gate_status(st),
|
gate: gate_status(st),
|
||||||
|
freshness: freshness(st),
|
||||||
defs: {
|
defs: {
|
||||||
let d = st.defs.current();
|
let d = st.defs.current();
|
||||||
hound_api::DefsStatus {
|
hound_api::DefsStatus {
|
||||||
|
|
|
||||||
395
crates/houndd/src/release.rs
Normal file
395
crates/houndd/src/release.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
//! Is there a newer Hound, and are the definitions still fresh?
|
||||||
|
//!
|
||||||
|
//! Two different questions with two different answers, and the difference
|
||||||
|
//! matters.
|
||||||
|
//!
|
||||||
|
//! **Definitions update themselves.** They are Ed25519-signed and verified
|
||||||
|
//! before they are parsed, so a bad pack is discarded rather than loaded.
|
||||||
|
//! Applying one automatically adds no attack surface that fetching one did
|
||||||
|
//! not already add.
|
||||||
|
//!
|
||||||
|
//! **The application does not.** A daemon that can replace its own binary is
|
||||||
|
//! the single mechanism a supply-chain attacker most wants, and Hound runs as
|
||||||
|
//! root with the ability to block execution. So this module only ever
|
||||||
|
//! *reports* that a release exists. Installing it goes through the system
|
||||||
|
//! package manager, with the user present and authenticating.
|
||||||
|
//!
|
||||||
|
//! The manifest that says a release exists is signed with the same key as the
|
||||||
|
//! definition packs, for the same reason the packs are: whoever serves
|
||||||
|
//! `dl.houndav.com` must not be able to invent a version and point our users
|
||||||
|
//! at it. An unsigned or badly-signed manifest is discarded, and the absence
|
||||||
|
//! of a manifest means "no update known", never "update available".
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Where release manifests live. Overridable for testing and for air-gapped
|
||||||
|
/// mirrors, which are a real deployment rather than a hypothetical.
|
||||||
|
pub fn base_url() -> String {
|
||||||
|
std::env::var("HOUNDD_RELEASE_URL").unwrap_or_else(|_| "https://dl.houndav.com".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manifest is a few hundred bytes. Anything approaching this is not one.
|
||||||
|
const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
/// What the publisher signed. Serialised canonically for signing, so the
|
||||||
|
/// bytes that are verified are the bytes that are interpreted — never a
|
||||||
|
/// re-encoding that could differ.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Release {
|
||||||
|
/// Semantic version of the newest published build, e.g. "0.2.0".
|
||||||
|
pub version: String,
|
||||||
|
/// Where a human goes to read what changed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes_url: String,
|
||||||
|
/// Where the .deb lives, for the one-click install path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deb_url: String,
|
||||||
|
/// SHA-256 of that .deb, so the download can be checked before it is
|
||||||
|
/// handed to the package manager.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deb_sha256: String,
|
||||||
|
/// ISO-8601 date the release was published.
|
||||||
|
#[serde(default)]
|
||||||
|
pub published: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manifest as served: a signed envelope around the release.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SignedRelease {
|
||||||
|
pub key_id: String,
|
||||||
|
/// Hex-encoded Ed25519 signature over `canonical(release)`.
|
||||||
|
pub signature: String,
|
||||||
|
pub release: Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact bytes that are signed and verified.
|
||||||
|
///
|
||||||
|
/// Signing a re-serialisation of a parsed struct is a classic way to verify
|
||||||
|
/// one thing and act on another, so the canonical form is defined once here
|
||||||
|
/// and used by both sides.
|
||||||
|
pub fn canonical(r: &Release) -> String {
|
||||||
|
format!(
|
||||||
|
"hound-release-v1\nversion={}\nnotes_url={}\ndeb_url={}\ndeb_sha256={}\npublished={}\n",
|
||||||
|
r.version, r.notes_url, r.deb_url, r.deb_sha256, r.published
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(r: &Release, key: &ed25519_dalek::SigningKey, key_id: &str) -> SignedRelease {
|
||||||
|
use ed25519_dalek::Signer as _;
|
||||||
|
let sig = key.sign(canonical(r).as_bytes());
|
||||||
|
SignedRelease {
|
||||||
|
key_id: key_id.to_string(),
|
||||||
|
signature: hex_encode(&sig.to_bytes()),
|
||||||
|
release: r.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(signed: &SignedRelease, trusted: &[(&str, VerifyingKey)]) -> Result<Release> {
|
||||||
|
let key = trusted
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == signed.key_id)
|
||||||
|
.map(|(_, k)| k)
|
||||||
|
.with_context(|| format!("no trusted key with id {:?}", signed.key_id))?;
|
||||||
|
let raw = hex_decode(&signed.signature).context("the signature is not hex")?;
|
||||||
|
let bytes: [u8; 64] = raw
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("a signature is 64 bytes, this one is {}", raw.len()))?;
|
||||||
|
key.verify_strict(canonical(&signed.release).as_bytes(), &Signature::from_bytes(&bytes))
|
||||||
|
.context("the manifest signature does not verify")?;
|
||||||
|
Ok(signed.release.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and verify the release manifest. `Ok(None)` means the server has no
|
||||||
|
/// manifest — a normal state, and distinct from one that fails to verify.
|
||||||
|
pub fn fetch(base: &str, trusted: &[(&str, VerifyingKey)]) -> Result<Option<Release>> {
|
||||||
|
let url = format!("{}/latest.json", base.trim_end_matches('/'));
|
||||||
|
let resp = ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.user_agent(concat!("hound/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.get(&url)
|
||||||
|
.call();
|
||||||
|
let resp = match resp {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||||
|
Err(e) => return Err(anyhow::anyhow!("fetching {url}: {e}")),
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
use std::io::Read as _;
|
||||||
|
resp.into_reader()
|
||||||
|
.take(MAX_MANIFEST_BYTES + 1)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.with_context(|| format!("reading {url}"))?;
|
||||||
|
if buf.len() as u64 > MAX_MANIFEST_BYTES {
|
||||||
|
bail!("{url} is larger than {MAX_MANIFEST_BYTES} bytes; refusing it");
|
||||||
|
}
|
||||||
|
let signed: SignedRelease = serde_json::from_slice(&buf).context("decoding the manifest")?;
|
||||||
|
Ok(Some(verify(&signed, trusted)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `candidate` newer than `current`?
|
||||||
|
///
|
||||||
|
/// Compares dot-separated numeric components, so 0.10.0 beats 0.9.0 — which
|
||||||
|
/// a string comparison gets backwards, and which is exactly the version pair
|
||||||
|
/// where a naive implementation would first be noticed.
|
||||||
|
pub fn is_newer(candidate: &str, current: &str) -> bool {
|
||||||
|
let parts = |v: &str| -> Vec<u64> {
|
||||||
|
v.trim()
|
||||||
|
.trim_start_matches('v')
|
||||||
|
.split(['.', '-', '+'])
|
||||||
|
.map(|p| p.parse::<u64>().unwrap_or(0))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let (a, b) = (parts(candidate), parts(current));
|
||||||
|
for i in 0..a.len().max(b.len()) {
|
||||||
|
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||||
|
if x != y {
|
||||||
|
return x > y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_decode(s: &str) -> Result<Vec<u8>> {
|
||||||
|
if s.len() % 2 != 0 {
|
||||||
|
bail!("odd-length hex string");
|
||||||
|
}
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).context("bad hex digit"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
|
||||||
|
fn a_key() -> SigningKey {
|
||||||
|
SigningKey::from_bytes(&[7u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn a_release() -> Release {
|
||||||
|
Release {
|
||||||
|
version: "0.2.0".into(),
|
||||||
|
notes_url: "https://houndav.com/changelog".into(),
|
||||||
|
deb_url: "https://dl.houndav.com/deb/hound_0.2.0_amd64.deb".into(),
|
||||||
|
deb_sha256: "aa".repeat(32),
|
||||||
|
published: "2026-08-21".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_signed_manifest_verifies() {
|
||||||
|
let k = a_key();
|
||||||
|
let signed = sign(&a_release(), &k, "hound-2026");
|
||||||
|
let trusted = [("hound-2026", k.verifying_key())];
|
||||||
|
assert_eq!(verify(&signed, &trusted).unwrap(), a_release());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point. Whoever serves dl.houndav.com must not be able to
|
||||||
|
/// invent a version and point our users at a binary of their choosing.
|
||||||
|
#[test]
|
||||||
|
fn a_tampered_manifest_is_refused() {
|
||||||
|
let k = a_key();
|
||||||
|
let mut signed = sign(&a_release(), &k, "hound-2026");
|
||||||
|
signed.release.deb_url = "https://evil.example/hound.deb".into();
|
||||||
|
let trusted = [("hound-2026", k.verifying_key())];
|
||||||
|
assert!(verify(&signed, &trusted).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_signed_field_is_covered_by_the_signature() {
|
||||||
|
let k = a_key();
|
||||||
|
let trusted = [("hound-2026", k.verifying_key())];
|
||||||
|
for mutate in [
|
||||||
|
(|r: &mut Release| r.version = "9.9.9".into()) as fn(&mut Release),
|
||||||
|
|r: &mut Release| r.notes_url = "https://evil.example".into(),
|
||||||
|
|r: &mut Release| r.deb_url = "https://evil.example/x.deb".into(),
|
||||||
|
|r: &mut Release| r.deb_sha256 = "bb".repeat(32),
|
||||||
|
|r: &mut Release| r.published = "1999-01-01".into(),
|
||||||
|
] {
|
||||||
|
let mut signed = sign(&a_release(), &k, "hound-2026");
|
||||||
|
mutate(&mut signed.release);
|
||||||
|
assert!(
|
||||||
|
verify(&signed, &trusted).is_err(),
|
||||||
|
"a field outside the signature would let a CDN operator lie about it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_manifest_signed_by_a_stranger_is_refused() {
|
||||||
|
let signed = sign(&a_release(), &SigningKey::from_bytes(&[9u8; 32]), "hound-2026");
|
||||||
|
let trusted = [("hound-2026", a_key().verifying_key())];
|
||||||
|
assert!(verify(&signed, &trusted).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_key_id_is_refused() {
|
||||||
|
let k = a_key();
|
||||||
|
let signed = sign(&a_release(), &k, "somebody-elses-key");
|
||||||
|
let trusted = [("hound-2026", k.verifying_key())];
|
||||||
|
assert!(verify(&signed, &trusted).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String comparison says "0.9.0" > "0.10.0". Numeric comparison does
|
||||||
|
/// not, and this is the version pair where that first bites.
|
||||||
|
#[test]
|
||||||
|
fn versions_compare_numerically_not_lexically() {
|
||||||
|
assert!(is_newer("0.10.0", "0.9.0"));
|
||||||
|
assert!(!is_newer("0.9.0", "0.10.0"));
|
||||||
|
assert!(is_newer("1.0.0", "0.99.99"));
|
||||||
|
assert!(is_newer("0.2.0", "0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_version_is_not_an_update() {
|
||||||
|
assert!(!is_newer("0.1.0", "0.1.0"));
|
||||||
|
assert!(!is_newer("v0.1.0", "0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Never offer a downgrade as an update — that is how a signed-but-old
|
||||||
|
/// manifest becomes a way to reintroduce a fixed vulnerability.
|
||||||
|
#[test]
|
||||||
|
fn an_older_version_is_never_offered() {
|
||||||
|
assert!(!is_newer("0.0.9", "0.1.0"));
|
||||||
|
assert!(!is_newer("0.1.0", "0.2.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_components_are_handled() {
|
||||||
|
assert!(is_newer("0.1.1", "0.1"));
|
||||||
|
assert!(!is_newer("0.1", "0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manifest is signed by tools/publish-release.py in Python and
|
||||||
|
/// verified here in Rust. Two implementations of the same canonical form
|
||||||
|
/// is exactly where this silently breaks — a field reordered on one side
|
||||||
|
/// produces manifests that verify nowhere, and the symptom is that
|
||||||
|
/// nobody ever hears about an update.
|
||||||
|
///
|
||||||
|
/// This is a real manifest produced by that tool, checked against the
|
||||||
|
/// production public key compiled into the daemon.
|
||||||
|
#[test]
|
||||||
|
fn a_manifest_from_the_publishing_tool_verifies() {
|
||||||
|
const PUBLISHED: &str = r#"{
|
||||||
|
"key_id": "hound-2026",
|
||||||
|
"signature": "0e4fdce35b9220d6c471499d4ad035890cec050fd47ee9e54b29cec138c74370c7a889486b40eaf9b85ab3938e8c70208c724d0ebcd4fd8340af09431ab6da03",
|
||||||
|
"release": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"notes_url": "https://houndav.com/#changelog",
|
||||||
|
"deb_url": "https://dl.houndav.com/deb/hound_0.1.0_amd64.deb",
|
||||||
|
"deb_sha256": "50117edbfd09bafe005fd658c3348e8bd7dcdfeb5cbabc8eb58f57b4b92bda08",
|
||||||
|
"published": "2026-08-21"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let signed: SignedRelease =
|
||||||
|
serde_json::from_str(PUBLISHED).expect("the tool emits valid JSON");
|
||||||
|
let key_bytes: [u8; 32] = crate::defs::production_key();
|
||||||
|
let key = VerifyingKey::from_bytes(&key_bytes).unwrap();
|
||||||
|
let out = verify(&signed, &[("hound-2026", key)])
|
||||||
|
.expect("the Rust verifier must accept what the Python signer produced");
|
||||||
|
assert_eq!(out.version, "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon reports its own version to the update check, and the
|
||||||
|
/// desktop app declares one separately in two more files. If those drift,
|
||||||
|
/// a released version can look older than what is installed and the
|
||||||
|
/// update silently never offers — or worse, offers forever.
|
||||||
|
#[test]
|
||||||
|
fn the_declared_versions_all_agree() {
|
||||||
|
let daemon = env!("CARGO_PKG_VERSION");
|
||||||
|
for (file, needle) in [
|
||||||
|
("../../gui/src-tauri/Cargo.toml", "version = \""),
|
||||||
|
("../../gui/src-tauri/tauri.conf.json", "\"version\": \""),
|
||||||
|
("../../gui/package.json", "\"version\": \""),
|
||||||
|
] {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(file);
|
||||||
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||||
|
continue; // a source checkout without the GUI is still valid
|
||||||
|
};
|
||||||
|
let found = text
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.trim().strip_prefix(needle))
|
||||||
|
.and_then(|r| r.split('"').next())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
found, daemon,
|
||||||
|
"{} declares {found:?}, the daemon is {daemon:?}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a manifest over a real socket and fetch it the way the scheduler
|
||||||
|
/// does. The tests above prove the cryptography; this proves the wiring
|
||||||
|
/// between it and the network, which is the part that fails silently — a
|
||||||
|
/// daemon that never fetches looks exactly like one where no update
|
||||||
|
/// exists.
|
||||||
|
fn serve_once(body: String, status: &'static str) -> String {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Ok((mut sock, _)) = listener.accept() {
|
||||||
|
let mut scratch = [0u8; 2048];
|
||||||
|
let _ = sock.read(&mut scratch);
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = sock.write_all(head.as_bytes());
|
||||||
|
let _ = sock.write_all(body.as_bytes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://127.0.0.1:{port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_served_manifest_is_fetched_and_verified() {
|
||||||
|
let k = a_key();
|
||||||
|
let signed = sign(&a_release(), &k, "hound-2026");
|
||||||
|
let base = serve_once(serde_json::to_string(&signed).unwrap(), "200 OK");
|
||||||
|
let got = fetch(&base, &[("hound-2026", k.verifying_key())])
|
||||||
|
.expect("a well-signed manifest must fetch")
|
||||||
|
.expect("and must not be reported as absent");
|
||||||
|
assert_eq!(got.version, "0.2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A host with no manifest is the normal state before the first release.
|
||||||
|
/// It must read as "nothing newer known", not as an error the scheduler
|
||||||
|
/// logs every day.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_manifest_is_not_an_error() {
|
||||||
|
let base = serve_once("nope".into(), "404 Not Found");
|
||||||
|
let got = fetch(&base, &[("hound-2026", a_key().verifying_key())]).unwrap();
|
||||||
|
assert!(got.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the signature exists for: someone controlling the download
|
||||||
|
/// host serves a manifest we did not sign. It must not become a prompt
|
||||||
|
/// telling users to install their binary.
|
||||||
|
#[test]
|
||||||
|
fn a_served_manifest_we_did_not_sign_is_refused() {
|
||||||
|
let attacker = SigningKey::from_bytes(&[3u8; 32]);
|
||||||
|
let signed = sign(&a_release(), &attacker, "hound-2026");
|
||||||
|
let base = serve_once(serde_json::to_string(&signed).unwrap(), "200 OK");
|
||||||
|
assert!(fetch(&base, &[("hound-2026", a_key().verifying_key())]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_round_trips() {
|
||||||
|
let bytes = [0u8, 1, 15, 16, 255];
|
||||||
|
assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
|
||||||
|
assert!(hex_decode("abc").is_err());
|
||||||
|
assert!(hex_decode("zz").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
BIN
dist/hound_0.1.1_amd64.deb
vendored
Normal file
BIN
dist/hound_0.1.1_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",
|
"name": "hound-gui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hound-gui",
|
"name": "hound-gui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "hound-gui",
|
"name": "hound-gui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Hound Antivirus — desktop app",
|
"description": "Hound Antivirus — desktop app",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
116
gui/src-tauri/Cargo.lock
generated
116
gui/src-tauri/Cargo.lock
generated
|
|
@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-api"
|
name = "hound-api"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1477,17 +1477,19 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound-gui"
|
name = "hound-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hound-api",
|
"hound-api",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2953,6 +2955,20 @@ dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|
@ -2981,6 +2997,41 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -3386,6 +3437,12 @@ version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
|
|
@ -4287,6 +4344,28 @@ version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
|
|
@ -4530,6 +4609,24 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|
@ -4760,6 +4857,15 @@ dependencies = [
|
||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|
@ -5236,6 +5342,12 @@ dependencies = [
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|
|
||||||
|
|
@ -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.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.joelovestech.com/Hound/Antivirus"
|
repository = "https://git.joelovestech.com/Hound/Antivirus"
|
||||||
|
|
@ -17,6 +17,8 @@ tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
ureq = { version = "2", default-features = false, features = ["tls", "gzip"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
|
|
|
||||||
BIN
gui/src-tauri/icons/state-attention-22.png
Normal file
BIN
gui/src-tauri/icons/state-attention-22.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
|
|
@ -52,7 +52,12 @@ const POLL_SECS: u64 = 1;
|
||||||
static SCANNING: AtomicBool = AtomicBool::new(false);
|
static SCANNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// States the tray can render. Anything unknown falls back to `protected`.
|
/// States the tray can render. Anything unknown falls back to `protected`.
|
||||||
const STATES: [&str; 4] = ["protected", "scanning", "threat", "paused"];
|
/// Tray states, in the order a viewer would rank their urgency. "attention"
|
||||||
|
/// is the persistent one — an update waiting, or definitions going stale —
|
||||||
|
/// as distinct from "scanning", which lasts seconds and which the user
|
||||||
|
/// started themselves. They are both amber; attention is the deeper shade,
|
||||||
|
/// so the two are distinguishable side by side.
|
||||||
|
const STATES: [&str; 5] = ["protected", "scanning", "threat", "paused", "attention"];
|
||||||
|
|
||||||
/// The four preloaded state icons, managed so tray swaps never hit disk.
|
/// The four preloaded state icons, managed so tray swaps never hit disk.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
|
|
@ -130,6 +135,148 @@ async fn settings() -> Result<Settings, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Assisted update ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Download the published package, check it, and hand it to the system
|
||||||
|
/// package manager.
|
||||||
|
///
|
||||||
|
/// Hound does not replace its own binary. A root daemon that can rewrite
|
||||||
|
/// itself is precisely the mechanism a supply-chain attacker wants, so the
|
||||||
|
/// install goes through `apt`, with the user present and authenticating —
|
||||||
|
/// the same path they would take by hand, minus the typing.
|
||||||
|
///
|
||||||
|
/// Two checks before anything is handed over, because a signed manifest
|
||||||
|
/// establishes what the publisher intended and nothing more: the URL must be
|
||||||
|
/// on our own download host, and the file must hash to what the manifest
|
||||||
|
/// said. The manifest's signature is what makes that hash trustworthy.
|
||||||
|
fn install_update(app: &tauri::AppHandle) {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let proceed = app
|
||||||
|
.dialog()
|
||||||
|
.message(format!(
|
||||||
|
"Hound {} is available.\n\nThe package will be downloaded, checked against \
|
||||||
|
its signed manifest, and installed by your system package manager. You will \
|
||||||
|
be asked to authenticate.",
|
||||||
|
pending.version
|
||||||
|
))
|
||||||
|
.title(format!("Install Hound {}?", pending.version))
|
||||||
|
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||||
|
"Install".into(),
|
||||||
|
"Not now".into(),
|
||||||
|
))
|
||||||
|
.blocking_show();
|
||||||
|
if !proceed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fail = |msg: String| {
|
||||||
|
let _ = app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title("Update failed")
|
||||||
|
.body(&msg)
|
||||||
|
.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// A signed manifest says what the publisher intended; it does not stop a
|
||||||
|
// publisher's mistake pointing somewhere else. Refuse anything off our
|
||||||
|
// own download host.
|
||||||
|
if !pending.deb_url.starts_with("https://dl.houndav.com/") {
|
||||||
|
fail("The update points somewhere unexpected and was not downloaded.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = match ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.get(&pending.deb_url)
|
||||||
|
.call()
|
||||||
|
{
|
||||||
|
Ok(r) => {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
use std::io::Read as _;
|
||||||
|
// 256 MB is far beyond any real package and well short of
|
||||||
|
// filling a disk.
|
||||||
|
if r.into_reader().take(256 << 20).read_to_end(&mut buf).is_err() {
|
||||||
|
fail("The download did not complete.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
fail(format!("Could not download the update: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let got = Sha256::digest(&body);
|
||||||
|
let got = got.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||||
|
if !pending.sha256.is_empty() && got != pending.sha256 {
|
||||||
|
fail("The downloaded package does not match its signed checksum. It was discarded.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Into a root-owned directory, so nothing can swap the file between the
|
||||||
|
// check above and apt reading it.
|
||||||
|
let path = std::path::Path::new("/var/lib/hound/updates").join(format!(
|
||||||
|
"hound_{}_amd64.deb",
|
||||||
|
pending.version
|
||||||
|
));
|
||||||
|
if elevate_write(&path, &body).is_err() {
|
||||||
|
fail("Could not stage the update.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::process::Command::new("pkexec")
|
||||||
|
.arg("/usr/bin/apt-get")
|
||||||
|
.arg("install")
|
||||||
|
.arg("-y")
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(st) if st.success() => {
|
||||||
|
let _ = app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(format!("Hound {} installed", pending.version))
|
||||||
|
.body("The new version is active.")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
Ok(st) if st.code() == Some(126) => {} // dismissed; not a failure
|
||||||
|
Ok(_) => fail("The package manager refused the update.".into()),
|
||||||
|
Err(e) => fail(format!("Could not start the installer: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a staged package as root, via the same polkit path as everything
|
||||||
|
/// else that needs privilege.
|
||||||
|
fn elevate_write(path: &std::path::Path, body: &[u8]) -> Result<(), String> {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut child = std::process::Command::new("pkexec")
|
||||||
|
.arg("/usr/bin/hound")
|
||||||
|
.arg("stage-update")
|
||||||
|
.arg(path)
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.ok_or("no stdin")?
|
||||||
|
.write_all(body)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match child.wait() {
|
||||||
|
Ok(st) if st.success() => Ok(()),
|
||||||
|
Ok(st) => Err(format!("staging exited {:?}", st.code())),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Elevation ───────────────────────────────────────────────────────────────
|
// ── Elevation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Run one daemon method as root, via polkit.
|
/// Run one daemon method as root, via polkit.
|
||||||
|
|
@ -364,11 +511,38 @@ fn set_state(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A release the daemon has verified as newer, cached so the menu handler
|
||||||
|
/// does not have to make a round-trip while the user is waiting on a click.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PendingUpdate {
|
||||||
|
version: String,
|
||||||
|
deb_url: String,
|
||||||
|
sha256: String,
|
||||||
|
notes_url: String,
|
||||||
|
}
|
||||||
|
static PENDING_UPDATE: std::sync::Mutex<Option<PendingUpdate>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// The "Install update" entry, kept so the watcher can retitle and
|
||||||
|
/// enable it when the daemon learns a release exists.
|
||||||
|
struct UpdateMenuItem(MenuItem<tauri::Wry>);
|
||||||
|
|
||||||
|
/// The daemon's one-line reason for the amber state, so the tooltip can say
|
||||||
|
/// what needs attention rather than only that something does.
|
||||||
|
static ATTENTION_REASON: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
|
||||||
|
|
||||||
fn tooltip_for(state: &str) -> String {
|
fn tooltip_for(state: &str) -> String {
|
||||||
match state {
|
match state {
|
||||||
"threat" => "Hound — threat found".into(),
|
"threat" => "Hound — threat found".into(),
|
||||||
"scanning" => "Hound — scanning…".into(),
|
"scanning" => "Hound — scanning…".into(),
|
||||||
"paused" => "Hound — paused".into(),
|
"paused" => "Hound — paused".into(),
|
||||||
|
"attention" => {
|
||||||
|
let why = ATTENTION_REASON.lock().map(|g| g.clone()).unwrap_or_default();
|
||||||
|
if why.is_empty() {
|
||||||
|
"Hound — needs attention".into()
|
||||||
|
} else {
|
||||||
|
format!("Hound — {why}")
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => "Hound — protected".into(),
|
_ => "Hound — protected".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -415,6 +589,35 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
|
||||||
state = "paused";
|
state = "paused";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Amber for anything that needs the user but is not a threat: a
|
||||||
|
// published release, or definitions going stale. It ranks below
|
||||||
|
// a threat and below an in-flight scan, and above plain
|
||||||
|
// protected — it is a nudge, not an alarm.
|
||||||
|
if let Ok(st) = c.status() {
|
||||||
|
let f = &st.freshness;
|
||||||
|
*ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone();
|
||||||
|
if !f.update_version.is_empty() {
|
||||||
|
*PENDING_UPDATE.lock().expect("update lock") = Some(PendingUpdate {
|
||||||
|
version: f.update_version.clone(),
|
||||||
|
deb_url: f.update_deb_url.clone(),
|
||||||
|
sha256: f.update_deb_sha256.clone(),
|
||||||
|
notes_url: f.update_notes_url.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(item) = app.try_state::<UpdateMenuItem>() {
|
||||||
|
if f.update_version.is_empty() {
|
||||||
|
let _ = item.0.set_text("No update available");
|
||||||
|
let _ = item.0.set_enabled(false);
|
||||||
|
} else {
|
||||||
|
let _ = item.0.set_text(format!("Install Hound {}…", f.update_version));
|
||||||
|
let _ = item.0.set_enabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state == "protected" && f.wants_attention() {
|
||||||
|
state = "attention";
|
||||||
|
}
|
||||||
|
}
|
||||||
apply_state(&app, &icons, state);
|
apply_state(&app, &icons, state);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -443,11 +646,12 @@ fn notify(app: &tauri::AppHandle, ev: &Event) {
|
||||||
// applications menu, the setup hook failed and the whole app panicked before
|
// 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
|
// 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.
|
// binary, and it makes the tray icon unable to be missing.
|
||||||
const ICON_BYTES: [(&str, &[u8]); 4] = [
|
const ICON_BYTES: [(&str, &[u8]); 5] = [
|
||||||
("protected", include_bytes!("../icons/state-protected-22.png")),
|
("protected", include_bytes!("../icons/state-protected-22.png")),
|
||||||
("scanning", include_bytes!("../icons/state-scanning-22.png")),
|
("scanning", include_bytes!("../icons/state-scanning-22.png")),
|
||||||
("threat", include_bytes!("../icons/state-threat-22.png")),
|
("threat", include_bytes!("../icons/state-threat-22.png")),
|
||||||
("paused", include_bytes!("../icons/state-paused-22.png")),
|
("paused", include_bytes!("../icons/state-paused-22.png")),
|
||||||
|
("attention", include_bytes!("../icons/state-attention-22.png")),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn load_state_icons() -> R<HashMap<String, Image<'static>>> {
|
fn load_state_icons() -> R<HashMap<String, Image<'static>>> {
|
||||||
|
|
@ -507,11 +711,30 @@ pub fn run() {
|
||||||
)?;
|
)?;
|
||||||
let update_sig =
|
let update_sig =
|
||||||
MenuItem::with_id(&handle, "update", "Update Signatures", true, None::<&str>)?;
|
MenuItem::with_id(&handle, "update", "Update Signatures", true, None::<&str>)?;
|
||||||
|
// Two items that exist only when they mean something. A permanently
|
||||||
|
// greyed "Install update" teaches people the menu is decorative;
|
||||||
|
// these are enabled/disabled from the watcher as the daemon's
|
||||||
|
// answer changes, and their labels carry the version.
|
||||||
|
let install_item = MenuItem::with_id(
|
||||||
|
&handle,
|
||||||
|
"install-update",
|
||||||
|
"No update available",
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)?;
|
||||||
let sep = PredefinedMenuItem::separator(&handle)?;
|
let sep = PredefinedMenuItem::separator(&handle)?;
|
||||||
let quit = MenuItem::with_id(&handle, "quit", "Quit", true, None::<&str>)?;
|
let quit = MenuItem::with_id(&handle, "quit", "Quit", true, None::<&str>)?;
|
||||||
let menu = Menu::with_items(
|
let menu = Menu::with_items(
|
||||||
&handle,
|
&handle,
|
||||||
&[&open, &scan_home, &scan_downloads, &update_sig, &sep, &quit],
|
&[
|
||||||
|
&open,
|
||||||
|
&scan_home,
|
||||||
|
&scan_downloads,
|
||||||
|
&update_sig,
|
||||||
|
&install_item,
|
||||||
|
&sep,
|
||||||
|
&quit,
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let _ = TrayIconBuilder::with_id(TRAY_ID)
|
let _ = TrayIconBuilder::with_id(TRAY_ID)
|
||||||
|
|
@ -570,6 +793,10 @@ pub fn run() {
|
||||||
json!({ "action": "scan", "path": "~/Downloads" }),
|
json!({ "action": "scan", "path": "~/Downloads" }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"install-update" => {
|
||||||
|
let app = app.clone();
|
||||||
|
std::thread::spawn(move || install_update(&app));
|
||||||
|
}
|
||||||
"update" => {
|
"update" => {
|
||||||
let _ = window.emit("tray-event", json!({ "action": "update" }));
|
let _ = window.emit("tray-event", json!({ "action": "update" }));
|
||||||
}
|
}
|
||||||
|
|
@ -587,6 +814,7 @@ pub fn run() {
|
||||||
})
|
})
|
||||||
.build(&handle)?;
|
.build(&handle)?;
|
||||||
|
|
||||||
|
app.manage(UpdateMenuItem(install_item.clone()));
|
||||||
start_watcher(handle.clone(), watcher_icons);
|
start_watcher(handle.clone(), watcher_icons);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -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.0",
|
"version": "0.1.1",
|
||||||
"identifier": "com.joelovestech.hound",
|
"identifier": "com.joelovestech.hound",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|
|
||||||
81
tools/publish-release.py
Executable file
81
tools/publish-release.py
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sign and publish the release manifest that tells installed agents a new
|
||||||
|
version exists.
|
||||||
|
|
||||||
|
The manifest is signed with the same Ed25519 key as the definition packs, and
|
||||||
|
for the same reason: whoever serves dl.houndav.com must not be able to invent
|
||||||
|
a version and point our users at a binary of their choosing. An agent discards
|
||||||
|
a manifest that does not verify, so a mistake here is a silent no-update
|
||||||
|
rather than a bad update.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tools/publish-release.py --version 0.2.0 --deb dist/hound_0.2.0_amd64.deb \
|
||||||
|
[--notes-url https://houndav.com/changelog] [--out /srv/houndav/dl]
|
||||||
|
"""
|
||||||
|
import argparse, hashlib, json, pathlib, subprocess, sys, datetime
|
||||||
|
|
||||||
|
KEY = pathlib.Path.home() / "agents/hound/.secrets/defs-signing.key"
|
||||||
|
KEY_ID = "hound-2026"
|
||||||
|
|
||||||
|
# Must match release.rs::canonical exactly. Signing a re-serialisation of a
|
||||||
|
# parsed struct is a classic way to verify one thing and act on another, so
|
||||||
|
# both sides build these bytes from the same field order and separators.
|
||||||
|
def canonical(r):
|
||||||
|
return (
|
||||||
|
"hound-release-v1\n"
|
||||||
|
f"version={r['version']}\n"
|
||||||
|
f"notes_url={r['notes_url']}\n"
|
||||||
|
f"deb_url={r['deb_url']}\n"
|
||||||
|
f"deb_sha256={r['deb_sha256']}\n"
|
||||||
|
f"published={r['published']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--version", required=True)
|
||||||
|
ap.add_argument("--deb", required=True, type=pathlib.Path)
|
||||||
|
ap.add_argument("--notes-url", default="https://houndav.com/#changelog")
|
||||||
|
ap.add_argument("--out", type=pathlib.Path, default=pathlib.Path("/srv/houndav/dl"))
|
||||||
|
ap.add_argument("--deb-url", default=None)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
if not a.deb.is_file():
|
||||||
|
sys.exit(f"no such package: {a.deb}")
|
||||||
|
if not KEY.is_file():
|
||||||
|
sys.exit(f"no signing key at {KEY}")
|
||||||
|
|
||||||
|
digest = hashlib.sha256(a.deb.read_bytes()).hexdigest()
|
||||||
|
deb_url = a.deb_url or f"https://dl.houndav.com/deb/{a.deb.name}"
|
||||||
|
if not deb_url.startswith("https://dl.houndav.com/"):
|
||||||
|
# The agent refuses anything off our own host, so publishing one would
|
||||||
|
# produce an update nobody can install.
|
||||||
|
sys.exit(f"the agent will refuse {deb_url}: it must be on dl.houndav.com")
|
||||||
|
|
||||||
|
release = {
|
||||||
|
"version": a.version,
|
||||||
|
"notes_url": a.notes_url,
|
||||||
|
"deb_url": deb_url,
|
||||||
|
"deb_sha256": digest,
|
||||||
|
"published": datetime.date.today().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nacl.signing import SigningKey
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("pip install pynacl")
|
||||||
|
seed = KEY.read_bytes()
|
||||||
|
if len(seed) == 64:
|
||||||
|
seed = seed[:32]
|
||||||
|
sk = SigningKey(seed)
|
||||||
|
sig = sk.sign(canonical(release).encode()).signature.hex()
|
||||||
|
|
||||||
|
manifest = {"key_id": KEY_ID, "signature": sig, "release": release}
|
||||||
|
a.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = a.out / "latest.json"
|
||||||
|
dest.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
print(f"wrote {dest}")
|
||||||
|
print(f" version {a.version} sha256 {digest[:16]}…")
|
||||||
|
print(f" public key {sk.verify_key.encode().hex()}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue