I wanted one place that tells me what is going on in my homelab: who is poking at it from outside, which boxes have known-exploited CVEs, what needs patching, and whether anything new turned up on the network. And I wanted it on a small box, without putting an agent on every machine.
The obvious tools are good, and I'll say that up front. Wazuh does far more than this, but it wants an agent per host and its all-in-one install asks for 4 vCPU and 8 GB. CrowdSec is brilliant at reading logs and banning, but it bans on its own, and behind a Cloudflare tunnel that matters more than you'd think. So I built a smaller thing that glues existing scanners together, and I've just launched it. It's called warden, it's on GitHub under AGPL-3.0, and this is how it works and what went wrong while I built it.
Repo: github.com/casareanderson/warden
What it is (and isn't)
warden is plain Python and one SQLite file. Collectors run on systemd timers and write to the database. The console, a REST API and an MCP server read that file and nothing else. It reaches other machines over SSH, so there's nothing to install on them.
What it does:
-
Log detection. It reads the reverse proxy log (NPMplus/nginx), the Authelia log and the cloudflared tunnel log. It scores things like
/.envand/.git/configprobes, path traversal, SQL injection, scanner user agents and login failures. It proposes bans per IP and per /24. - Vulnerabilities. trivy scans host packages and running Docker images. Anything on CISA's known-exploited list is flagged.
-
Patching. It writes a patch plan per box (apt, or
docker compose pull && up -dfor containers). It also finds Proxmox LXC containers by itself. -
Hardening and drift. Lynis scores each box, and a file integrity check flags changes to things like
sshd_config, cron and systemd units. - Network. Suricata for IDS, matching against threat feeds (FireHOL level 1, Feodo, OpenPhish), plus new-device alerts on the LAN.
- Edge. It snapshots the Cloudflare side: public hostnames, WAF rules, which names sit behind SSO.
What it isn't: it isn't EDR in the industry sense. There's no agent and no process telemetry. The scanning is done by trivy, Lynis and Suricata, not by me. warden is the bit in the middle that joins it all up, scores it, and asks you what to do.
How it compares
Every piece of this exists somewhere else, often done better. So here's where it sits, from each project's own docs:
| Tool | Good at | How warden differs |
|---|---|---|
| Wazuh | Full SIEM: vulns, file integrity, active response | Agent on every host, all-in-one install wants 4 vCPU / 8 GB. warden is agentless, tiny, and does less |
| CrowdSec | Log parsing, community blocklists | Its bouncers block on their own. warden asks first, and is built for a Cloudflare tunnel. They run fine together |
| PatchMon | Patching with approvals, Proxmox LXC | Agent per host plus Postgres and Redis. warden patches over SSH and does detection too |
| Vuls | Agentless vuln scanning over SSH | A scanner. warden wraps trivy with patch plans and approvals |
| Security Onion | Network monitoring, Suricata, hunting | A full platform with far bigger hardware needs. warden just reads your Suricata alerts |
| Fail2ban | Bans from log patterns | Single-purpose and automatic. Behind a tunnel its firewall bans hit the tunnel, not the attacker |
So the gap warden fills is the combination: agentless, one small box, edge bans from the logs you already have, and nothing changes until you say yes.
It runs on 1 vCPU and 512 MB for the core. I measured the console at 29 MB resident on my box. Vulnerability scanning adds roughly 1 GB of RAM and 2 GB of disk, mostly trivy's database.
Rule one: it never acts on its own
Out of the box warden is detect-only. It records what it would ban and changes nothing.
When it does want to act, it asks. A proposed edge ban or a patch plan turns up in Discord with ✅ and ❌, and nothing happens until I tap one. The same decisions show in the console.
Two reasons for this.
The first is the tunnel. My public services sit behind a Cloudflare tunnel. Behind a tunnel, the network-layer source of every request is cloudflared or the Cloudflare edge. The attacker's address only exists in the HTTP layer. So a local firewall ban keyed on the attacker never matches a packet. And banning what does show up at layer 3 means banning Cloudflare, which takes the whole estate offline. That's why there's no iptables code in warden at all. Bans go to Cloudflare's IP Access Rules at the edge, before the tunnel.
The second is that automatic bans need a detector you trust completely, and I don't trust mine that much. The rest of this post is why.
The bugs that shaped it
Event time has to come from the log line
The first time warden read the Authelia log, it read the whole backlog at once and stamped every event with the time it was ingested. That put weeks of July history inside the "last 60 minutes" ban window. 19 of 19 events landed in the window. With bans switched on, that would have banned people for things they did in July.
The fix is a timestamp extractor per source that reads the time from the line itself. After that, 0 old events landed in the window.
A related one: a fixed --since 10m lookback under a 5-minute timer counts every event twice. That quietly pushes scores towards a ban. It's now a real watermark per source, plus a dedup table.
A per-IP threshold misses a spread-out attacker
Seven addresses in one /24 each scored between 3 and 6, under the per-IP threshold of 12. Nothing fired. Now scores roll up by /24 as well. A range ban needs a higher total (20) and at least 3 different addresses. That's stricter on purpose, because blocking a /24 is a much blunter instrument. It also refuses any range that contains an allowlisted address, like my own.
(Those seven turned out to be old OIDC client-auth failures, not a live brute force. Check the dates before calling something an attack.)
My own IP was the loudest attacker
This one was this morning. 1,526 of the last 1,577 events came from my own WAN address. An Uptime Kuma monitor was hitting a URL that 404s once a minute, and every 404 scored.
My own IPs were already excluded from bans. But they still filled the counts, the timeline and the recent list, and pushed the real rows off the screen. Now they're dropped from every count, and the console says how many it ignored. After the fix the real number for the day was 51.
The first version of that fix had its own bug, and a test caught it. I fetched 20 times the rows I needed and then filtered. With 150 of my own rows sitting newer than the real ones, it came back with nothing. It now pages through until it has enough real rows.
The patcher that replied 328 times
The patcher writes to the database and posts to Discord. The Discord helper also writes to the database, on its own connection. The patcher was posting while still holding an uncommitted write. So the helper got database is locked, the run died before it committed, the job stayed pending, and two minutes later it posted "⌛ expired" again. 328 messages in one day, and the loop also blocked two jobs I'd already approved.
The rule now: commit before any notify call. I found the same bug in the edge-ban module and fixed it there too.
A setting that was a root shell
The day after v2 went public I had an independent review done on the code. It found 8 problems, and I fixed all of them with a regression test for each. The worst: the Suricata host was an editable setting in the console, and it was passed to ssh. Something like -oProxyCommand=... would have been root code execution from a settings box. That setting is gone. Settings the console can edit now live in a separate overrides file, and secrets are write-only.
Another was a token on the command line. The trivy server was started with --token in its arguments, which any local user can read with ps. Today it reads it from a 0600 environment file instead, and the scanners get it on stdin.
A slow page on a slow disk
People said the console was slow to open. The server built its data in 0.3 seconds, and the browser drew it in 36 ms. But the first load after a quiet spell took 8.9 seconds. The database is 100 MB on a 5400 rpm disk shared with other guests, and a cold read waited behind everything else. The console now keeps one warm copy, rebuilds it in the background, and drops it the moment you change anything. A load now takes about 0.15 seconds.
Talking to it from an AI agent
warden has a read-only REST API and an MCP server, both behind bearer tokens. Tokens are hashed at rest and can be revoked. My Hermes security agent connects to it and gets 13 tools: summary, detections, top IPs, an IP lookup, bans, vulnerabilities, a CVE lookup, integrity, IDS and so on. So the agent can answer "anything dodgy last night?" from facts in the database instead of guessing.
It's read-only on purpose. An agent can look, but it can't approve anything. Approvals stay with a person.
One gotcha if you put SSO in front of it: let /mcp and /api/v1 straight through. An MCP client has no browser and can't follow a login redirect, so the bearer token is the auth on those two paths. Everything else stays behind the login.
What's missing
Being honest about the gaps:
- "Sign in with…". warden now has its own login (users, viewer/approver/admin roles, optional 2FA, lockout), added today. What it doesn't have yet is signing in with your own identity provider directly; for now you put your SSO in front.
- Chat tools. Approvals only go to Discord right now. Slack, Teams, Telegram and Matrix are next.
- Ban expiry. Bans record an expiry, but nothing lifts them yet, so Cloudflare rules pile up.
- Threat-list blocking. It matches traffic against threat feeds but doesn't turn them into blocks yet. When it does, a script will build the list and you'll approve it. The AI won't pick who gets blocked.
Try it
It's free and open source (AGPL-3.0). The core runs happily in a small LXC. There's a setup wizard that checks the running system rather than just your config file, and a demo data script if you just want to look at the console first.
github.com/casareanderson/warden
If you self-host, I'd like to know what you'd want from it. Which of the gaps above would you hit first, or what else is missing? Tell me in the comments and I'll build the top ones.





Top comments (3)
When you turn threat lists into bans, watch feeds that flag entire cloud ASNs as proxy. CI and monitoring egress sharing that ASN gets collateral damage. We require multiple specialized sources before treating a datacenter range as proxy; a single-source DC mark is usually noise.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.