Armed the execution gate on the live server for the first time. It
reported itself armed on a dedicated tmpfs, and then let EICAR execute.
Counters: 0 allowed, 0 blocked. Not one event was ever delivered.
Cause: systemd gives the service a PRIVATE MOUNT NAMESPACE. Several
perfectly ordinary hardening options force one — ProtectProc,
ProtectKernelTunables, ProtectControlGroups — and none of them mention
it. FAN_MARK_MOUNT marks a vfsmount, and a private namespace holds its
own vfsmount for the same filesystem. So the daemon marked its copy,
every other process on the machine used the host's copy, and the gate
protected nothing while claiming to be armed.
That is the worst way for a security feature to fail: silently, with a
reassuring status line. Nothing in the unit tests could have caught it —
they run in the host namespace, where the mount mark works.
Fixed by always using FAN_MARK_FILESYSTEM, which marks the SUPERBLOCK.
A superblock is shared across namespaces, so events arrive from
everywhere, and scoping still works because a superblock is exactly one
filesystem: marking a dedicated mount covers that mount and nothing
else. mark_mount is kept for the smoke-test example, which runs outside
systemd, with a doc comment about when it lies to you.
Two more that only appeared once the gate was actually armed:
* SystemCallFilter=@system-service kills the daemon with SIGSYS the
moment the gate is switched on. fanotify_init and fanotify_mark live
in @privileged, which @system-service deliberately excludes. Granted
individually rather than by adding @privileged, which would also admit
setuid, chroot, bpf and kexec_load. Invisible until armed — the
service starts fine with the gate off.
* The capability reduction reported "60 capabilities could not be
dropped" while the end state was perfectly correct. systemd's
CapabilityBoundingSet had already done the work, and the service does
not hold CAP_SETPCAP afterwards, so every redundant drop failed EPERM.
It now checks what is actually present, attempts only that, and judges
by the end state rather than by return codes.
Also removed AmbientCapabilities from the unit. Ambient capabilities are
inherited by children, the daemon shells out to freshclam/rpm/pacman on
some paths, and a root process already receives the bounding set as
permitted — so it bought nothing except a way for CAP_SYS_ADMIN to leak
into a subprocess.
Performance, measured on the live server rather than guessed at:
+2.70 ms/exec as first written
+1.47 ms/exec after the reader blocked on poll() instead of sleeping
a millisecond between empty reads — that sleep sat on
the critical path of every execve
+1.38 ms/exec after answering cache hits in the reader thread, with
no channel handoff or worker wakeup
2,680 execs/sec sustained through the gate, 16-way parallel, with
ZERO watchdog rescues — the queue never fell behind. Ungated is 7,455.
Caddy stayed at sub-millisecond throughout and load did not rise.
Joe and Henry are right that the exec-heavy paths on this box — Docker
overlays, agent workspaces, PM2 — are the performance bar rather than an
exclusion list. Protecting agent workspaces from injected payloads is
the product. 2,680/sec with no backlog is roughly ten times what this
machine generates, so the bar looks clearable; stage 2 will say for sure.
295 tests pass, and the three-phase gate smoke test still passes
including the fail-open case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|---|---|---|
| assets/icons | ||
| crates | ||
| dist | ||
| gui | ||
| packaging | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
| rust-toolchain.toml | ||
Hound Antivirus
A premium, freemium antivirus for Linux. One engine, three faces:
a Rust daemon (houndd), a CLI (hound), and a Tauri GUI with a
system-tray sentinel that changes color with your security state.
Built for the distros people actually run: Ubuntu, Debian, Linux Mint, and anything else that ships ClamAV.
Repository layout
antivirus/
├── Cargo.toml # Rust workspace
├── crates/
│ ├── hound-api/ # shared wire types + socket client (daemon/CLI/GUI all use it)
│ ├── houndd/ # the daemon: Unix-socket API over a pluggable engine
│ └── hound/ # CLI client
├── assets/icons/ # dog-head brand mark + 4-state tray ladder
└── gui/ # Tauri 2 desktop app (system tray + scan UI)
Architecture
houndd (Rust daemon — the engine)
┌──────────────────────────────────┐
│ ScanEngine trait │
│ ├─ L1 ClamAV signatures (now) │
│ ├─ L2 Curated threat packs(Pro)│
│ ├─ L3 Behavioral monitor (Pro) │
│ └─ L4 Supply-chain checks(Pro) │
└──────────────┬───────────────────┘
Unix socket (JSON-RPC, line-delimited)
┌───────────┼───────────┐
hound CLI GUI (Tauri) future modules
The daemon is the only process that touches a scanning engine. CLI and GUI are thin clients — so future suite tools (firewall, updater, …) plug into the same socket.
Swapping the engine (the ClamAV seam)
ClamAV is a temporary dependency. Everything ClamAV-specific — version
probe, signature freshness, the clamscan subprocess + output parsing,
freshclam — lives in one file behind a four-method trait:
crates/houndd/src/engine.rs
trait ScanEngine { name; probe; scan; update }
struct ClamAvEngine // today
const ENGINE: ClamAvEngine // ← flip this line when the native
// engine lands; nothing else in the
// daemon, CLI, GUI, or wire API moves
The wire stays engine-agnostic: Status.engine names the implementation
("clamav" today) and Status.db carries what any signature store has —
a file name and a timestamp. When our own Rust engine ships, it's a new
ScanEngine implementation, a one-const flip, and the tray/CLI/GUI simply
start reporting the new engine name.
Icon system
The brand mark is a solid dog head (assets/icons/hound.svg), a single
flat fill. It ships in two treatments:
- Brand ladder
hound-{16,22,24,32,48,256}.png— native periwinkle#9896E0, for the window icon, About box, and marketing. - Tray-state ladder
state-<name>-{16,22,24,32,48}.png— the same path re-tinted per security state, for the system-tray sentinel:
| State | Fill | Meaning |
|---|---|---|
protected |
#22C55E green |
up-to-date / protected / clean |
scanning |
#F59E0B amber |
scan in progress / signatures need update |
threat |
#EF4444 red |
infection found |
paused |
#6B7280 gray |
real-time monitor off |
Green is the good state; amber is work in progress, never a failure.
Quickstart (development)
Prereqs: Rust (see rust-toolchain.toml), Node 20+, ClamAV, and the
Tauri system libs (libwebkit2gtk-4.1-dev, libgtk-3-dev, libayatana-appindicator3-dev).
# 1. Signatures (needs the clamav freshclam DB)
sudo freshclam
# 2. Daemon (terminal 1)
cargo run -p houndd
# 3. Scan (terminal 2)
cargo run -p hound -- status
cargo run -p hound -- scan ~/Downloads
# 4. GUI
cd gui && npm install && npm run tauri dev
Verifying the engine with the EICAR test file
EICAR is the industry-standard 68-byte test signature — every AV that works will flag it. Generate it and scan it:
printf 'X5O!P%%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.com
cargo run -p hound -- scan /tmp/eicar.com
# expect: exit code 1, "Eicar-Test-Signature FOUND"
Updating signatures
hound update wraps freshclam (trying sudo freshclam first, since
plain-user runs can't write /var/lib/clamav and /var/log/clamav). The
GUI's "Update Signatures" button drives the same RPC and shows the log.
cargo run -p hound -- update # or: hound update --json
The GUI (gui/)
A Tauri 2 desktop app — a thin view over the same houndd socket the
CLI uses (via the shared hound-api client), so the window and the
command line never disagree about your security state.
gui/
├── dist/ # the front-end (vanilla HTML/CSS/JS, premium dark shell)
└── src-tauri/ # Tauri 2 shell + system-tray sentinel
The tray sentinel swaps the 4-state icons (green/amber/red/gray) as your state changes; the window shows a live protection hero, a scan progress bar, a results table, and the signature-update log.
Build it:
cd gui
npm install
npm run tauri dev # dev with hot reload
npm run tauri build # → .deb in src-tauri/target/release/bundle/
Git conventions
-
Branch
mainis deployable; small, focused commits. -
No hardcoded secrets. For pushes, the bot token lives in a repo-local credential file (never tracked):
git config credential.helper 'store --file=.git/.git-credentials' chmod 600 .git/.git-credentials echo 'https://<user>:<token>@git.joelovestech.com' >> .git/.git-credentials -
Commits: imperative subject, ≤ 72 chars. Example:
houndd: add line-delimited JSON-RPC socket API
License
TBD — core daemon likely proprietary (freemium), shared CLI possibly OSS.
Decision pending; workspace.package.license = MIT is a placeholder.