A local host-monitoring and input-forensics stack for Linux, shipped as one static Go binary that runs on any popular systemd distro (built and tested on Manjaro/Arch, GNOME/Wayland, zsh). It was born from a concrete question -- "why do commands I never typed sometimes appear in my terminal?" -- and grew into a small, self-contained system that records what happens at the input, shell, and process layers, encrypts the evidence at rest, and presents it in a password-protected local dashboard.
Overview — monitor health (zsh / input / audit), encrypted-store status, live throughput, and the offline GeoIP map of current connections.
Status: complete and in use -- input/shell/exec monitoring, encrypted storage, CLI, web dashboard, and the network panel (listening processes by bandwidth, reverse DNS, offline GeoIP country + world map).
Implemented as a single, statically-linked Go binary with no interpreter or shared-library dependency at runtime; the dashboard markup and world map are embedded via
go:embed. The only non-Go runtime piece is the zsh hook (it must run inside the shell). The sole optional external tool isnvidia-smi(GPU VRAM).
- Three correlated planes -- prompt text (even unexecuted), per-keystroke source device, and auditd execve / uinput / USB -- so you can tell typed from pasted from injected after the fact.
- Encrypted at rest -- a LUKS2 log store whose key is sealed to the TPM via
systemd-creds; the writers refuse to start unless it is mounted. - Local web dashboard on
127.0.0.1:8799-- scrypt login, per-load CSRF token, Host allowlist + Origin / Sec-Fetch checks; Overview / Network / Processes / Logs / Timeline. - Network panel -- per-process bandwidth read from the kernel over netlink (no
ss/ iproute2 dependency), reverse DNS, and an offline GeoIP world-map choropleth. - One static Go binary -- pure standard library, no interpreter or shared
libraries, assets embedded via
go:embed; builds for amd64 / arm64 / arm / 386.
make build # -> ./linaudit (static, no runtime deps)
sudo sh install.sh # detect user, install, init store, enable services, run doctor
linaudit doctor # readiness check you can run on any distro
Then open http://127.0.0.1:8799/ -- the first visit sets the dashboard password.
Most "shell auditing" records only executed commands. The original symptom here was text that appeared in the prompt and was never executed, so conventional history/exec auditing was blind to it. LinAudit instead watches three planes and correlates them by timestamp, which lets you tell typed from pasted from injected after the fact.
| Plane | Component | What it captures |
|---|---|---|
| Prompt text (typed or pasted, even unexecuted) + executed commands | zsh hooks (shell/linaudit.zsh) |
every line-editor change + every preexec command |
| Physical / virtual keystroke source | linaudit-input service (inputmon/) |
each key event tagged with its source /dev/input device; flags new/virtual (uinput) devices |
Executions, /dev/uinput access, USB add/remove |
auditd rules + udev rule (system/) |
execve, uinput-injection signature, BadUSB enumeration |
Correlation key: prompt text that grows one character at a time alongside KEY
events from a real keyboard was typed; text that appears all at once with no
preceding KEY was pasted or injected; keystrokes from a virtual device or a
uinput open are a software injector. See docs/runbook.md.
All captured logs live in a LUKS2 container (storage/), mounted at
/var/log/linaudit. The container key is sealed to the machine's TPM
(systemd-creds, host+tpm2) and released automatically at boot -- the raw key is
never written to disk in plaintext.
- Protects against: a pulled/stolen disk, booting another OS, and backups / btrfs snapshots -- all see ciphertext only.
- Does NOT protect against: a live root attacker on the running machine (the key is in the kernel keyring while mounted). Use full-disk encryption for that.
- The writers hard-depend on the mount (
Requires=linaudit-store.service), so if the store cannot be unlocked they refuse to start rather than write plaintext.
- Web UI on
http://127.0.0.1:8799/(web/), bound to loopback only. First visit sets a password (scrypt-hashed); login issues an HttpOnly, SameSite=Strict session cookie. Per-load CSRF token, Host allowlist (anti DNS-rebind), and Origin/Sec-Fetch checks on top. - Layout: a collapsible sidebar (Overview / Network / Processes / Logs / Timeline) with a persistent status rail -- monitor health pills (click to toggle), encryption badge, live counts, and the global live/pause + interval control -- visible in every section.
- Features: live/pause auto-refresh, global throughput sparklines + per-process trends, proportional bandwidth bars, global totals, an alert banner when a monitor is off / storage is plaintext, filter/search, sortable tables (including by process and a descending-time "age" column), new-connection/-country highlighting, scroll + text-selection preservation across refreshes, keyboard shortcuts (1-5 sections, / filter, p pause, r refresh), per-tab log views (newest first), correlated timeline, offline world-map choropleth, enable/disable each layer, logout.
- Processes view: per-process CPU%, resident memory, and NVIDIA GPU VRAM (via nvidia-smi, capturing both graphics and compute procs), plus a system CPU / RAM / GPU summary; sortable by any column and filterable.
- CLI panel:
linaudit(status | enable | disable | logs | report | live | open | doctor);web/input/store up|down|initare the service entry points of the same binary.linaudit doctorreports, on any distro, exactly which planes will work here.
The dashboard's network section answers "what is my machine talking to":
- Processes by bandwidth -- every listening or actively-connected process, sorted
by current rx+tx (delta of the kernel's per-socket
tcp_infobyte counters read over netlink, sampled every 2s), with its listening ports. - Remote connections -- each established peer with reverse-DNS hostname and GeoIP country flag. LAN/private/CGNAT/multicast peers are classified locally and excluded from rDNS, GeoIP, and the map.
- World map -- an offline choropleth highlighting the countries of current connections.
Socket data is read straight from the kernel over netlink (NETLINK_SOCK_DIAG /
INET_DIAG, including per-socket tcp_info byte counters), so there is no
dependency on the ss/iproute2 binary. Everything is offline except reverse-DNS,
which uses the system resolver (same as normal browsing) and can be turned off.
GeoIP is a bundled local DB; no observed IP is ever sent to a third party. (UDP
exposes no per-socket byte counters, so UDP shows as connections without bandwidth.)
The runtime is one statically-linked binary built from the Go packages
(cmd/linaudit plus web, web/netmon, web/procmon, inputmon, storage,
cli). The dashboard HTML and world map are compiled into it via go:embed.
| Repo path | Deployed to | Notes |
|---|---|---|
cmd/linaudit + the Go packages |
/usr/local/bin/linaudit |
one static binary; every role is a subcommand |
web/dashboard.html, web/world.svg |
embedded in the binary | via go:embed -- no sidecar files at runtime |
shell/linaudit.zsh |
~/.config/zsh/linaudit.zsh |
sourced from ~/.zshrc (the only non-Go runtime piece) |
inputmon/linaudit-input.service |
/etc/systemd/system/ |
ExecStart=/usr/local/bin/linaudit input |
web/linaudit-web.service |
/etc/systemd/system/ |
ExecStart=/usr/local/bin/linaudit web |
storage/linaudit-store.service |
/etc/systemd/system/ |
ExecStart=/usr/local/bin/linaudit store up; ordered before the writers |
data/fetch-geoip.sh |
downloads to /usr/local/share/linaudit/geoip/ |
offline IP->country DB (CC BY 4.0) |
system/linaudit.rules |
/etc/audit/rules.d/ |
auditd (augenrules --load) |
system/97-linaudit.rules |
/etc/udev/rules.d/ |
USB/input add-remove -> journal |
system/linaudit.logrotate |
/etc/logrotate.d/linaudit |
daily, 14 days; installer templates the monitored user |
system/linaudit-dashboard.json |
/etc/brave/policies/managed/ |
sets the dashboard as Brave homepage |
install.sh |
run as root | detect user, build + install everything, store init, enable services, run doctor |
(generated) /etc/linaudit/linaudit.env |
written by installer | LINAUDIT_USER=<user>; sourced by the store + web units |
test/inject/ |
go run ./test/inject (root) |
simulates a software keystroke injector (uinput) |
linaudit store init (root, run once) creates the LUKS2 container and seals the key
to the TPM; it replaces the former secure-init.sh / secure-mount.sh shell
scripts, now folded into the binary (linaudit store up|down).
Runtime dependencies: systemctl / journalctl (the systemd base) and, for the
encrypted store, cryptsetup + systemd-creds (systemd >= 250). A TPM2 device
hardware-binds the store key; without one the key seals host-only (with a warning).
auditd/auditctl (exec/uinput/USB plane) and nvidia-smi (GPU VRAM) are optional
and degrade gracefully when absent. No Python, no iproute2, no shared libraries. Run
linaudit doctor to see exactly what is available on a given host.
Runtime secrets and captured data live OUTSIDE the repo and are git-ignored:
the TPM-sealed key (/etc/linaudit/store.key.cred), the password hash
(/var/lib/linaudit/auth.json), the encrypted container
(/var/lib/linaudit/store.img), and all *.log files.
Build (Go >= 1.21; standard library only, so the build is offline and reproducible and cross-compiles to amd64/arm64/arm/386):
make build # -> ./linaudit (CGO_ENABLED=0 static, ~7 MB, no runtime deps)
make test # parity vectors, parsers, classify, scrypt, evdev decode, identity
One-step install on any systemd distro (privileged; creates an encrypted volume -- review it first):
sudo sh install.sh
It auto-detects the monitored user (override with LINAUDIT_USER=), installs the
binary + units + configs, templates the logrotate user, fetches the offline GeoIP DB
(SKIP_GEOIP=1 to skip), runs linaudit store init (SKIP_STORE=1 to skip),
enables the services, installs the zsh hook, and finishes with linaudit doctor.
Prefer to do it by hand: install ./linaudit to /usr/local/bin/; the three units
from inputmon/, web/, storage/ to /etc/systemd/system/; the configs from
system/ (substituting __LINAUDIT_USER__ in the logrotate file); run
sudo linaudit store init then systemctl enable --now linaudit-store linaudit-input linaudit-web; sudo sh data/fetch-geoip.sh; and source
shell/linaudit.zsh from your ~/.zshrc. If auditd is installed, augenrules --load.
- Logs are local and 0600, encrypted at rest, never transmitted.
- The keystroke log is effectively a keylogger (root-only). Disable any time with
linaudit disable input. - The web service is root and localhost-only; worst case for an authenticated abuse is toggling monitoring -- there is no arbitrary-command path.
- The monitored user is auto-detected (
LINAUDIT_USER>SUDO_USER> current userstore-dir owner > sole login account) and
install.shpins it in/etc/linaudit/linaudit.env; only multi-user hosts with noLINAUDIT_USERneed manual selection. System paths under/usr/local,/var/lib,/var/log,/etc/linauditare fixed. The web bind address is overridable viaLINAUDIT_ADDR. - Targets systemd Linux (all popular distros: Ubuntu/Debian/Fedora/RHEL/Arch/
openSUSE/Mint/Pop!_OS). The static binary is distro/libc-agnostic; the units and
configs assume systemd, and the encrypted store needs systemd >= 250 + cryptsetup.
zsh is required only for the shell plane; auditd, TPM2, Brave, and nvidia-smi are
optional and degrade gracefully.
linaudit doctorreports what is present. - Builds for amd64/arm64/arm/386 (verified). Tested on amd64; the evdev decode and ioctl numbers assume the asm-generic encoding (x86/arm/arm64), so exotic arches (mips/ppc/sparc) would need a check.
- No uninstaller yet;
makecovers build/test/install-binary andinstall.shdoes the full system install. - Planned/optional: UDP per-process bandwidth (no per-socket UDP byte counters), and diff-patch table rendering so refresh does not reset scroll/selection.
- GeoIP:
ip-location-dbgeo-whois-asn-country (CC BY 4.0, by NRO) -- fetched viadata/fetch-geoip.sh, not vendored. - World map:
simple-world-map(web/world.svg) by Al MacDonald / Fritz Lekschas, CC BY-SA 3.0.
Apache License 2.0 -- see LICENSE. Third-party data/assets retain their own
licenses (see above).
