The start-menu entry ran `hound` with Terminal=true — the CLI, which
printed help and exited. No GUI binary had ever been built or packaged.
Four separate faults were stacked behind that report:
1. build-deb.sh now builds and ships hound-gui, and writes a .desktop
entry only when that binary exists. A launcher for software that is
not there is worse than no launcher.
2. Tray icons were loaded from a relative "icons/" path, which resolves
only from the build tree. Installed to /usr/bin the setup hook failed
and Tauri panicked before a window appeared. They are include_bytes!
now — four ~1 KB PNGs that can no longer be missing.
3. The front-end never ran at all. app.js opened with a bare module
specifier ("@tauri-apps/api/core") and there is no bundler, so the
webview could not resolve it and the script silently failed to parse.
The window rendered its static HTML forever, which looks exactly like
a daemon that never answered. withGlobalTauri + window.__TAURI__.
4. build-deb.sh ran the Tauri build as `>/dev/null 2>&1 || true`, so a
config error scrolled past unseen and the package shipped the
PREVIOUS binary. Two fixes appeared to do nothing. That step is no
longer silenced or tolerant of failure, and the build fails outright
on a bare import in gui/dist/*.js.
Guards, because each of these failed quietly: index.html flips to an
interface-error message if app.js never sets a boot flag within 5s. An
antivirus showing "Protected - your system looks healthy" while its own
front-end is dead is the worst failure mode there is.
Then the window came up and could not reach the daemon: the socket was
0700 root:root. Widening it needed more than a chmod, because
quarantine.restore writes files back out as root — handing that to a
desktop group would hand out root. So the daemon now checks SO_PEERCRED
per method (crates/houndd/src/peer.rs):
- group `hound`: status, settings.get, events, quarantine.list,
rootkit.scan, persistence.scan
- scan/supply.sweep: only paths the caller could read itself, decided
by forking a child, dropping to the peer's uid, gid and
supplementary groups, and asking access(2) — which honours ACLs and
mount options, unlike anything reconstructed from mode bits
- everything that writes: root, or the uid the daemon runs as
Unclassified methods fall into Admin, so a new mutating method fails
closed rather than becoming public by omission. The end-to-end socket
test caught that "root only" broke every developer run; the owner
clause collapses to "root" under the packaged root daemon and is
verified to do so.
CAP_SETUID/CAP_SETGID join the gate capability set for the readability
check. There was a test asserting CAP_SETUID must never be retained —
it is updated with the reasoning rather than deleted. The daemon
already holds CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH, so becoming
another user widens nothing that matters. The unit gains Group=hound so
the socket can be chgrp'd without CAP_CHOWN; it stays uid 0.
Also: the footer claimed "engine: ClamAV via Unix socket". It reports
what the daemon actually loaded, which has been yara-x since the engine
was replaced. Every error path in the front-end goes through explain(),
so a privilege refusal reads as "run it from a terminal: sudo hound …"
rather than "daemon error -32000".
358 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
5.3 KiB
Desktop File
115 lines
5.3 KiB
Desktop File
[Unit]
|
|
Description=Hound Antivirus daemon
|
|
Documentation=https://houndav.com/docs
|
|
After=network.target
|
|
# The gate is only useful while the filesystem it watches is mounted.
|
|
RequiresMountsFor=/var/lib/hound
|
|
|
|
[Service]
|
|
Type=exec
|
|
ExecStart=/usr/bin/houndd
|
|
# Stated explicitly so the daemon and the CLI cannot drift apart.
|
|
Environment=HOUNDD_SOCK=/run/hound/houndd.sock
|
|
Restart=on-failure
|
|
RestartSec=2s
|
|
|
|
# ── Privilege ────────────────────────────────────────────────────────
|
|
#
|
|
# houndd reduces its own capabilities at startup (see crates/houndd/src/
|
|
# caps.rs). This bounding set is the second half of that: it protects the
|
|
# machine if the binary is ever replaced by one that does not, and it
|
|
# means the daemon never *holds* the rest even momentarily.
|
|
#
|
|
# It cannot be narrower. fanotify needs SYS_ADMIN; scanning arbitrary
|
|
# files needs DAC_READ_SEARCH; quarantining out of a directory owned by
|
|
# someone else needs DAC_OVERRIDE; stripping the execute bit off a file
|
|
# we do not own needs FOWNER.
|
|
# SETUID/SETGID let the daemon fork a child, become the user who sent a scan
|
|
# request, and ask the kernel whether that user could have opened the file —
|
|
# which is what stops an unprivileged caller using a root scanner to probe
|
|
# files it cannot read. It is not an escalation: DAC_OVERRIDE below already
|
|
# grants this process every file on the system.
|
|
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID
|
|
# AmbientCapabilities is deliberately NOT set. Ambient capabilities are
|
|
# inherited by child processes, and the daemon shells out to freshclam,
|
|
# rpm and pacman on some paths — none of which should start life holding
|
|
# CAP_SYS_ADMIN. A process running as root already receives everything in
|
|
# the bounding set as permitted and effective, so Ambient adds nothing
|
|
# here except a way for it to leak.
|
|
NoNewPrivileges=yes
|
|
|
|
# ── Filesystem ───────────────────────────────────────────────────────
|
|
#
|
|
# ProtectSystem=strict would be the obvious choice and is WRONG here: it
|
|
# remounts everything read-only, and quarantine has to remove a threat
|
|
# from wherever it landed. ProtectHome is off for the same reason — most
|
|
# of what we quarantine is under /home.
|
|
ProtectSystem=false
|
|
ProtectHome=false
|
|
ReadWritePaths=/var/lib/hound /var/log/hound
|
|
StateDirectory=hound
|
|
LogsDirectory=hound
|
|
RuntimeDirectory=hound
|
|
RuntimeDirectoryMode=0750
|
|
# The daemon stays uid 0 — it has to read files no user can. Its *group* is
|
|
# `hound`, which is what lets it open the control socket to the desktop app
|
|
# without CAP_CHOWN: a process can already chgrp a file it owns to a group it
|
|
# belongs to. Membership of `hound` buys the read side of the API and nothing
|
|
# that writes; the daemon checks SO_PEERCRED per method (see peer.rs).
|
|
Group=hound
|
|
PrivateTmp=no
|
|
|
|
# ── Everything else we can shut off ──────────────────────────────────
|
|
ProtectKernelTunables=yes
|
|
ProtectKernelModules=yes
|
|
ProtectKernelLogs=yes
|
|
ProtectControlGroups=yes
|
|
ProtectClock=yes
|
|
ProtectHostname=yes
|
|
ProtectProc=invisible
|
|
RestrictNamespaces=yes
|
|
RestrictRealtime=yes
|
|
RestrictSUIDSGID=yes
|
|
LockPersonality=yes
|
|
|
|
# MemoryDenyWriteExecute is deliberately NOT set, and this is a real
|
|
# trade-off rather than an oversight.
|
|
#
|
|
# yara-x compiles rules to WebAssembly and JITs them, so it needs pages
|
|
# that are writable and then executable. With W^X enforced the daemon
|
|
# aborts at startup with "unable to make memory executable" — which is
|
|
# exactly what happened on the first real install, after the unit had
|
|
# passed systemd-analyze verify. A hardening directive that stops the
|
|
# service is worse than the exposure it prevents, because the machine
|
|
# ends up with no antivirus at all.
|
|
#
|
|
# What compensates: the scanner never executes scanned content, the
|
|
# capability set is four of forty-one, the syscall filter below blocks
|
|
# @module/@mount/@raw-io/@reboot, and the process cannot gain privileges.
|
|
# Revisit if yara-x ever ships an interpreter-only mode.
|
|
# MemoryDenyWriteExecute=yes
|
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
SystemCallArchitectures=native
|
|
SystemCallFilter=@system-service
|
|
# fanotify_init and fanotify_mark live in @privileged, which
|
|
# @system-service deliberately excludes — so the base filter kills the
|
|
# daemon with SIGSYS the moment the execution gate is switched on. This
|
|
# was invisible until the gate was armed for the first time on a real
|
|
# install: the service starts fine with the gate off.
|
|
#
|
|
# Granted individually rather than by adding @privileged, which would
|
|
# also admit setuid, chroot, bpf, kexec_load and pivot_root. Two
|
|
# syscalls is the whole requirement.
|
|
SystemCallFilter=fanotify_init fanotify_mark
|
|
SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @raw-io @reboot @swap
|
|
UMask=0077
|
|
|
|
# ── Resources ────────────────────────────────────────────────────────
|
|
# A scanner that eats the machine is its own denial of service.
|
|
MemoryMax=1G
|
|
TasksMax=64
|
|
LimitNOFILE=65536
|
|
OOMScoreAdjust=-500
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|