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>
274 lines
10 KiB
Bash
Executable file
274 lines
10 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# Build a .deb for Ubuntu / Debian / Linux Mint.
|
|
#
|
|
# Deliberately hand-rolled rather than cargo-deb: the package needs a
|
|
# postinst that creates the vault with the right mode, a conffile that
|
|
# survives upgrades, and a unit that is enabled but whose gate stays off
|
|
# until the operator turns it on. That is easier to read as a script than
|
|
# as a pile of metadata, and it is the thing most likely to need auditing.
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)"
|
|
ARCH="$(dpkg --print-architecture)"
|
|
OUT="${OUT:-$ROOT/dist}"
|
|
STAGE="$(mktemp -d)"
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
# mktemp -d creates 0700, and dpkg applies the staging root's mode to "/".
|
|
# Installing this package would chmod / to 0700 and break the machine.
|
|
chmod 0755 "$STAGE"
|
|
|
|
echo "building hound ${VERSION} (${ARCH})"
|
|
( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp )
|
|
|
|
# The desktop app. Optional: a build host without the webkit/gtk
|
|
# development libraries still produces a working CLI package, it just
|
|
# does not ship a launcher — which is better than shipping a menu entry
|
|
# for a binary that is not there.
|
|
GUI_BIN="$ROOT/gui/src-tauri/target/release/hound-gui"
|
|
if command -v npx >/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
|
|
# NOT silenced, and NOT tolerant of failure. Discarding this output once
|
|
# meant a config error scrolled past unseen and the package shipped the
|
|
# previous build's binary — the fix looked like it had no effect, twice.
|
|
( cd "$ROOT/gui" && npm install --no-audit --no-fund >/dev/null \
|
|
&& npx tauri build --no-bundle )
|
|
fi
|
|
# The webview loads dist/*.js directly, with no bundler. A bare module
|
|
# specifier there does not error loudly — it silently fails to resolve and the
|
|
# window renders its static HTML forever. Catch it here instead of in a bug
|
|
# report.
|
|
if grep -rnE '^\s*import .* from "[^./]' "$ROOT/gui/dist"/*.js 2>/dev/null; then
|
|
echo "ERROR: bare module specifier in the front-end; the webview cannot resolve it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ -x "$GUI_BIN" ]; then
|
|
HAVE_GUI=yes
|
|
echo " including the desktop app"
|
|
else
|
|
HAVE_GUI=no
|
|
echo " NOTE: no GUI binary — packaging the CLI only, and no launcher"
|
|
fi
|
|
|
|
install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd"
|
|
install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound"
|
|
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
|
|
[ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui"
|
|
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
|
|
"$STAGE/lib/systemd/system/houndd.service"
|
|
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
|
|
"$STAGE/usr/share/hound/rules/hound-builtin.yar"
|
|
install -Dm644 "$ROOT/README.md" "$STAGE/usr/share/doc/hound/README.md"
|
|
|
|
# Launcher icon: the white mark on a periwinkle tile (app-*.png), not the
|
|
# bare brand mark. The tray ladder is a different family and ships with
|
|
# the GUI, because tray glyphs must stay transparent to sit on any panel.
|
|
for size in 16 22 24 32 48 64 128 256 512; do
|
|
src="$ROOT/assets/icons/app-${size}.png"
|
|
[ -f "$src" ] && install -Dm644 "$src" \
|
|
"$STAGE/usr/share/icons/hicolor/${size}x${size}/apps/hound.png"
|
|
done
|
|
install -Dm644 "$ROOT/assets/icons/hound-app.svg" \
|
|
"$STAGE/usr/share/icons/hicolor/scalable/apps/hound.svg"
|
|
|
|
# A menu entry is a promise that clicking it opens something. It ships
|
|
# only when the desktop app does, and it launches THAT rather than the
|
|
# CLI — Exec=hound with Terminal=true opened a terminal, printed help and
|
|
# exited, which reads to anyone sane as "it does not launch".
|
|
if [ "$HAVE_GUI" = yes ]; then
|
|
install -Dm644 /dev/stdin "$STAGE/usr/share/applications/hound.desktop" <<'DESKTOP'
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=Hound Antivirus
|
|
GenericName=Antivirus
|
|
Comment=Endpoint and supply-chain protection for Linux
|
|
Exec=hound-gui
|
|
Icon=hound
|
|
Categories=System;Security;
|
|
Keywords=antivirus;malware;security;scan;supply chain;
|
|
Terminal=false
|
|
StartupWMClass=hound-gui
|
|
StartupNotify=true
|
|
DESKTOP
|
|
fi
|
|
|
|
mkdir -p "$STAGE/DEBIAN"
|
|
|
|
GUI_DEPENDS=""
|
|
[ "$HAVE_GUI" = yes ] && GUI_DEPENDS=", libwebkit2gtk-4.1-0, libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1"
|
|
|
|
cat > "$STAGE/DEBIAN/control" <<CONTROL
|
|
Package: hound
|
|
Version: ${VERSION}
|
|
Section: utils
|
|
Priority: optional
|
|
Architecture: ${ARCH}
|
|
Maintainer: Hound <support@houndav.com>
|
|
Depends: libc6 (>= 2.34)${GUI_DEPENDS}
|
|
Suggests: clamav-daemon
|
|
Homepage: https://houndav.com
|
|
Description: Hound Antivirus for Linux
|
|
Endpoint and supply-chain protection built for the distributions people
|
|
actually run. Scanning is yara-x in process; real-time protection uses
|
|
fanotify, so a binary can be refused at execve rather than reported
|
|
after it has already run.
|
|
.
|
|
The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and
|
|
covers the whole root filesystem, so turning it on is the operator's
|
|
decision: hound settings set exec_gate true
|
|
CONTROL
|
|
|
|
cat > "$STAGE/DEBIAN/conffiles" <<'CONFFILES'
|
|
/etc/hound/hound.toml
|
|
CONFFILES
|
|
|
|
install -Dm644 /dev/stdin "$STAGE/etc/hound/hound.toml" <<'CONF'
|
|
# Hound Antivirus configuration.
|
|
#
|
|
# Live settings are managed through `hound settings` and stored per user;
|
|
# this file holds the machine-wide defaults the daemon starts from.
|
|
|
|
# Deny execution until a verdict is returned. Needs CAP_SYS_ADMIN.
|
|
# Off by default: it covers the whole root filesystem, and that is the
|
|
# operator's call to make rather than the installer's.
|
|
exec_gate = false
|
|
|
|
# Mounts the gate covers. Empty means the root filesystem.
|
|
exec_gate_paths = []
|
|
|
|
# Never held for a verdict.
|
|
exclude_paths = ["/proc", "/sys", "/dev", "/run", "/var/lib/docker"]
|
|
|
|
# Files larger than this are allowed through unread.
|
|
max_file_size_mb = 100
|
|
|
|
# "quarantine" or "alert".
|
|
on_detect = "quarantine"
|
|
CONF
|
|
|
|
cat > "$STAGE/DEBIAN/postinst" <<'POSTINST'
|
|
#!/bin/sh
|
|
set -e
|
|
|
|
case "$1" in
|
|
configure)
|
|
# The desktop app runs as the logged-in user; the daemon runs as root.
|
|
# `hound` is how they meet. Membership grants the read side of the API
|
|
# only — status, scan results, the event log — because quarantine writes
|
|
# files back out as root and that is not something a group should confer.
|
|
if ! getent group hound >/dev/null 2>&1; then
|
|
addgroup --system hound >/dev/null 2>&1 || groupadd -r hound >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# Enrol whoever ran the install, since on a desktop that is the person who
|
|
# will open the app. Group membership only takes effect on their next
|
|
# login, which is why the notice below says so out loud.
|
|
ADMIN="${SUDO_USER:-${PKEXEC_UID:-}}"
|
|
case "$ADMIN" in
|
|
''|root) ADMIN="" ;;
|
|
[0-9]*) ADMIN="$(getent passwd "$ADMIN" | cut -d: -f1)" ;;
|
|
esac
|
|
if [ -n "$ADMIN" ] && getent group hound >/dev/null 2>&1; then
|
|
if ! id -nG "$ADMIN" 2>/dev/null | tr ' ' '\n' | grep -qx hound; then
|
|
adduser "$ADMIN" hound >/dev/null 2>&1 || usermod -aG hound "$ADMIN" >/dev/null 2>&1 || true
|
|
ADDED_TO_GROUP=yes
|
|
fi
|
|
fi
|
|
|
|
# The vault holds live malware: root-only, and on a filesystem where
|
|
# nothing in it can be executed even by accident.
|
|
mkdir -p /var/lib/hound/vault /var/lib/hound/rules /var/log/hound
|
|
chmod 0700 /var/lib/hound/vault
|
|
chmod 0755 /var/lib/hound /var/lib/hound/rules
|
|
chmod 0750 /var/log/hound
|
|
|
|
# The built-in rules are compiled INTO the binary; /var/lib/hound/rules
|
|
# is for additional packs only. Copying the built-ins there made the
|
|
# daemon compile them twice and log a duplicate-declaration error on
|
|
# every start. The copy under /usr/share is documentation, not input.
|
|
|
|
# Menus cache icons; without this the entry can appear blank until the
|
|
# user logs out, which is indistinguishable from a broken package.
|
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|
gtk-update-icon-cache -qtf /usr/share/icons/hicolor 2>/dev/null || true
|
|
fi
|
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
|
update-desktop-database -q /usr/share/applications 2>/dev/null || true
|
|
fi
|
|
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl daemon-reload || true
|
|
systemctl enable houndd.service || true
|
|
systemctl restart houndd.service || true
|
|
fi
|
|
|
|
echo ""
|
|
echo "Hound is installed and scanning on demand."
|
|
echo ""
|
|
if [ "${ADDED_TO_GROUP:-no}" = yes ]; then
|
|
echo "Added $ADMIN to the 'hound' group so the desktop app can talk to"
|
|
echo "the daemon. Log out and back in for that to take effect."
|
|
echo ""
|
|
fi
|
|
echo " hound status what the daemon sees"
|
|
echo " hound scan ~/Downloads scan a directory"
|
|
echo ""
|
|
echo "Real-time execution blocking is OFF until you turn it on:"
|
|
echo ""
|
|
echo " sudo hound settings set exec_gate true"
|
|
echo ""
|
|
echo "To let a coding assistant check repositories before trusting them,"
|
|
echo "add this to its MCP configuration:"
|
|
echo ""
|
|
echo ' { "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }'
|
|
echo ""
|
|
;;
|
|
esac
|
|
exit 0
|
|
POSTINST
|
|
|
|
cat > "$STAGE/DEBIAN/prerm" <<'PRERM'
|
|
#!/bin/sh
|
|
set -e
|
|
case "$1" in
|
|
remove|deconfigure)
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl stop houndd.service || true
|
|
systemctl disable houndd.service || true
|
|
fi
|
|
;;
|
|
esac
|
|
exit 0
|
|
PRERM
|
|
|
|
cat > "$STAGE/DEBIAN/postrm" <<'POSTRM'
|
|
#!/bin/sh
|
|
set -e
|
|
case "$1" in
|
|
purge)
|
|
# The vault is deliberately NOT removed on `remove`, only on `purge`,
|
|
# and even then only after saying so: it may be the sole copy of
|
|
# evidence somebody still needs.
|
|
echo "Removing the Hound quarantine vault at /var/lib/hound/vault"
|
|
rm -rf /var/lib/hound /var/log/hound
|
|
# Leave the group behind if anyone is still in it — removing it would
|
|
# silently strip a gid that could be referenced elsewhere on the system.
|
|
if getent group hound >/dev/null 2>&1 && [ -z "$(getent group hound | cut -d: -f4)" ]; then
|
|
delgroup --system hound >/dev/null 2>&1 || groupdel hound >/dev/null 2>&1 || true
|
|
fi
|
|
;;
|
|
esac
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl daemon-reload || true
|
|
fi
|
|
exit 0
|
|
POSTRM
|
|
|
|
chmod 0755 "$STAGE/DEBIAN/postinst" "$STAGE/DEBIAN/prerm" "$STAGE/DEBIAN/postrm"
|
|
|
|
mkdir -p "$OUT"
|
|
DEB="$OUT/hound_${VERSION}_${ARCH}.deb"
|
|
fakeroot dpkg-deb --build --root-owner-group "$STAGE" "$DEB" >/dev/null
|
|
echo "built $DEB"
|
|
dpkg-deb -I "$DEB" | sed 's/^/ /'
|