Guard: watch logs, ban misbehaving addresses in Housecarl

Files and the journal, patterns with one <IP>, bounties within a window,
bans in a Housecarl ban list with repeat offenders' bans growing; presets
for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot,
nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban;
a Debian package with a sandboxed unit; an end-to-end test with Housecarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
commit d06bf8d531
47 files changed
+4614

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# The guard command
One binary is the daemon and its tools. Every command reads
`/etc/guard/guard.yaml` (`--config` for another); `--json` prints JSON.
| Command | |
|---|---|
| `guard run` | watch and ban: the daemon, as `guard.service` runs it |
| `guard check` | check the configuration, compile the patterns, and ask Housecarl for each ban list (and its log, for `housecarl-drops`) |
| `guard config` | the configuration as read, with the defaults filled in |
| `guard config --example` | a commented example |
| `guard presets [PRESET]` | the presets, or one's patterns |
| `guard test WATCH [FILE\|-] [--lines N] [--all]` | what a watch's patterns find in a log, and whom they would ban ([presets.md](presets.md)) |
| `guard status` | the running Guard: each watch's lines, matches, bans and highest bounties, the recent bans and why any failed, and bans waiting for Housecarl. Asks the socket: as root or the guard user |
| `guard bans [--list L]` | the ban list's active bans, from Housecarl |
| `guard ban ADDRESS [--list L] [--protocol tcp --port 22]... [--time 1d] [--reason R]` | ban by hand |
| `guard unban ADDRESS [--list L]` | lift an address's bans |
| `guard version` | |
| `guard completion bash` | bash completion (the package installs it) |
`bans`, `ban` and `unban` use Guard's token, so they act on the ban
lists it may ban into. `hc bans`, `hc ban` and `hc unban` on the
firewall do the same for every list.
+126
View File
@@ -0,0 +1,126 @@
# Configuration
`/etc/guard/guard.yaml` is Guard's whole configuration. `guard config
--example` prints a commented one. Unknown settings are an error;
`guard check` checks the file, compiles every pattern and asks Housecarl
for the ban lists. Guard reads it when it starts:
`systemctl restart guard` after a change.
## housecarl
| Setting | | |
|---|---|---|
| `url` | | Housecarl's API: `https://router.lan:8070` |
| `socket` | | or its local socket, on the firewall: `/run/housecarl/housecarl.sock` |
| `token_file` (or `token`) | | the API token: one for the ban lists Guard bans into (`bans:write:LIST`) |
| `ca_cert` | | a PEM file with the CA of Housecarl's certificate, if private |
| `insecure` | `false` | don't check Housecarl's certificate |
| `list` | | the ban list a watch bans into unless it names another |
## never_ban
Addresses, prefixes and host names never banned, whatever the logs say:
```yaml
never_ban: [198.51.100.7, 2001:db8:ffff::/48, vps.example.org]
```
Host names are looked up when Guard starts and every ten minutes; if a
lookup fails, the last addresses are kept. Loopback, link-local and
multicast addresses are never banned either. Housecarl has its own list
too: it never bans the inside networks, the firewall's own addresses,
the machine asking, or a ban list's `never`.
## defaults
| Setting | Default | |
|---|---|---|
| `hits` | 5 | an address is banned once it matches this many times… |
| `within` | 10m | …within this long |
| `ban` | 1h | how long a first ban lasts |
| `repeat.factor` | 2 | each earlier ban of the address multiplies the time by this (1: bans don't grow) |
| `repeat.max` | 1w | the longest ban |
| `repeat.forget` | 1w | earlier bans older than this don't count |
Times are written `30s`, `10m`, `1h`, `1d`, `1w`, or combined (`1d12h`).
Housecarl cuts a ban longer than its list's longest.
## watch
Each watch is a log, its patterns, and what a ban is:
```yaml
watch:
- name: ssh
preset: sshd
ban: {protocol: tcp, port: 22}
- name: web-login
file: /var/log/nginx/access.log
match:
- '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
ignore: ['/healthz']
hits: 10
within: 5m
ban: {list: web, time: 6h}
```
| Setting | | |
|---|---|---|
| `name` | | the watch's name: letters, digits, `-`, `_`, `.` |
| `preset` | | built-in patterns and their usual source ([presets.md](presets.md)) |
| `file` | | a log file, or a glob (`/var/log/nginx/*access.log`) |
| `journal` | | `{unit: ssh.service}` and/or `{identifier: sshd}`; each a name or a list. Entries of any of the units and of any of the identifiers |
| `source` | | `housecarl-drops`: Housecarl's log of dropped packets |
| `match` | | patterns (Go regular expressions) with one `<IP>` each; added to a preset's |
| `ignore` | | lines matching any of these are skipped, even when they match |
| `hits`, `within` | defaults | |
| `ban.list` | `housecarl.list` | the ban list |
| `ban.protocol`, `ban.port` | every port | `tcp` or `udp`, and a port or service name, or a list (`[http, https]`) |
| `ban.time` | `defaults.ban` | a first ban's length |
| `protocol` | `tcp` | `housecarl-drops` only: `tcp`, `udp` or `any`. UDP sources can be faked, so a scanner could get someone else banned |
| `shadow` | `false` | `housecarl-drops` only: count what shadow mode would have dropped |
| `disabled` | `false` | skip the watch |
A watch has one source: `file`, `journal` or `source`. A preset brings
its own, which a `file` or `journal` given replaces.
### How lines are read
* **Files** are followed from their end when Guard starts: only new
lines count. A file that appears later is read from its start. A
rotated file (renamed, and a new one made) is read to its end, then
the new one from its start; a truncated one (logrotate's
`copytruncate`) is noticed while it is shorter than what was read, and
read again from its start. Each line counts at the time Guard reads
it: Guard parses no dates.
* **The journal** is read through `journalctl --follow`, from now on,
with each entry's own time. If journalctl ends, Guard starts it again
after the last entry it saw. The guard user needs the
`systemd-journal` group (the package's unit gives it).
* **Housecarl's log** is read every two seconds (`GET /log`): each
packet a logged rule (or a policy, with `log_policy_drops`) dropped or
rejected counts once. The token needs `log:read`.
### Bounties and bans
Each match adds one to the address's bounty in that watch; matches older
than `within` drop out. When the bounty reaches `hits`, Guard bans the
address and its bounty starts again from nothing. IPv6 addresses are
counted, and banned, by the prefix the ban list says (its `group_v6`,
/64 by default), so an attacker can't step around a ban inside their
/64.
A ban goes to Housecarl at once. If Housecarl can't be reached, it waits
(in the state file, so a restart doesn't lose it) and is sent again
every 30 seconds, for the time it has left. A ban Housecarl refuses (an
address it never bans, a list the token doesn't cover) isn't sent again:
`guard status` and the log say why.
## state, socket, log
| Setting | Default | |
|---|---|---|
| `state` | `/var/lib/guard/state.json` | repeat offenders, recent bans, bans waiting for Housecarl |
| `socket` | `/run/guard/guard.sock` | the local socket `guard status` asks; root and the guard user |
| `log.level` | `info` | `debug` also logs every match with its bounty |
| `log.format` | `text` | or `json` |
+72
View File
@@ -0,0 +1,72 @@
# Installing Guard
Guard runs on any Linux machine whose logs it should watch: a web server
behind the firewall, a mail server, the firewall itself. It needs
Housecarl 0.2 or later (ban lists) on the firewall, reachable from the
machine.
## 1. A ban list and a token, in Housecarl
On the firewall, make a ban list for this machine, and a user with a
token that may ban into it and nothing else:
```sh
hc banlist add web --scope hosts --host 10.0.10.57 --host 2001:db8:10::57
hc user add guard-web --no-password --grant bans:write:web
sudo hc token create web --user guard-web
```
The scope says what a banned address is kept from:
* `hosts`: this machine (its addresses), the usual choice for a server
behind the firewall. Port forwards to it are covered too.
* `firewall`: the firewall itself, for Guard running on the firewall.
* `everything`: the firewall and everything behind it.
A watch that reads Housecarl's own log (`housecarl-drops`) also needs
`--grant log:read`.
## 2. The package
```sh
sudo apt install ./guard_0.1.0_amd64.deb
```
It makes the `guard` user (in the `adm` and `systemd-journal` groups,
to read logs), installs `guard.service` without starting it, and writes
an example `/etc/guard/guard.yaml`.
From source instead (Go 1.27): `make && sudo make install`, then
`sudo systemd-sysusers guard.conf && sudo chgrp guard /etc/guard
/etc/guard/guard.yaml && sudo systemctl daemon-reload`.
## 3. The configuration
Put the token in `/etc/guard/token`:
```sh
sudo install -m 0640 -g guard /dev/stdin /etc/guard/token <<< 'housecarl_…'
```
and edit `/etc/guard/guard.yaml`: Housecarl's URL, the ban list, and the
watches ([configuration.md](configuration.md)). Then:
```sh
sudo -u guard guard check # the file, the patterns, Housecarl
sudo -u guard guard test ssh # what the ssh watch finds in the journal
sudo systemctl enable --now guard
guard status # as root
```
On the firewall itself, use Housecarl's local socket instead of the URL
(`housecarl.socket: /run/housecarl/housecarl.sock`), and give the guard
user access to it: `SupplementaryGroups=adm systemd-journal housecarl`
in `systemctl edit guard`. A token is still needed: Housecarl trusts
only root and its own user on the socket without one.
## fail2ban instead
If you already run fail2ban, Housecarl ships an action for it that bans
into a ban list the same way (Housecarl's docs/bans.md). Guard and
fail2ban can also run side by side, on different machines or watching
different logs.
+85
View File
@@ -0,0 +1,85 @@
# Presets and patterns
A preset is a watch Guard knows: its patterns, and where its program's
lines usually are. `guard presets` lists them; `guard presets sshd`
shows one's patterns.
```yaml
watch:
- name: ssh
preset: sshd
ban: {protocol: tcp, port: 22}
- name: ssh-file # the same, from a syslog file
preset: sshd
file: /var/log/auth.log
```
A watch's own `file` or `journal` replaces the preset's source, and its
`match` and `ignore` lines are added to the preset's.
| Preset | Source | Matches |
|---|---|---|
| `sshd` | journal, identifiers `sshd`, `sshd-session` | failed passwords and keys, unknown users, connections closed before login, too many attempts, failed key exchanges, garbage banners |
| `jarl`, `housecarl`, `steward` | journal, units `jarl.service`, `housecarl.service`, `steward.service` | their failed logins and refused API tokens (`msg="login failed" ip=…`, text or JSON logs) |
| `bind-xfr` | journal, identifier `named` | zone transfers BIND refused (TCP, so the address is real) |
| `postfix-sasl` | journal, identifiers `postfix/smtpd`, `postfix/submission/smtpd`, `postfix/smtps/smtpd` | failed SMTP logins |
| `dovecot` | journal, identifier `dovecot` | failed IMAP, POP3, submission and ManageSieve logins |
| `nginx-auth` | file `/var/log/nginx/error.log` | failed HTTP basic authentication |
| `housecarl-drops` | Housecarl's log of dropped packets | every TCP packet a logged rule dropped (on the firewall, for scans) |
OpenSSH 9.8 and later (Debian 13) logs logins from `sshd-session`, not
`sshd`; the preset reads both. Debian 13 has no `/var/log/auth.log`
unless rsyslog is installed: the journal is the place.
There's deliberately no preset for BIND's refused queries: they come
over UDP, whose source address anyone can fake, so banning on them would
let a stranger get your users' resolvers banned. Use BIND's response
rate limiting for floods.
## Writing patterns
A pattern is a Go regular expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax))
with `<IP>` once, where the address is:
```yaml
match:
- '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
- 'authentication failure; .* rhost=<IP>(?: |$)'
```
`<IP>` matches IPv4 and IPv6 addresses, bracketed (`[2001:db8::9]`),
IPv4-mapped (`::ffff:203.0.113.9`, counted as `203.0.113.9`) or with a
zone. The text it matches must be a real address: `12:34:56` isn't one.
Patterns match anywhere in a line unless anchored. A line from a syslog
file starts with its date, host and program (`Oct 22 10:00:01 web
sshd[123]: `); a journal entry is the message alone. The presets begin
with `(?:^|: )` so they work on both.
**Where the address is matters.** Many log lines carry text a client
chose: a user name, a URL, a reverse DNS name. If a pattern lets that
text supply the address, a client can get any address banned:
```
Failed password for invalid user x from 198.51.100.1 port 22 from 203.0.113.9 port 4444 ssh2
```
Anchor patterns at the end of the line, or put `<IP>` where the
program writes the address after the client's text, so it is the
program's address that matches. The presets do; `guard test` shows
what yours find. Programs that log the address first (Jarl, Housecarl,
Steward log `msg="login failed" ip=… user=…`) are the easiest to match
safely.
## Trying patterns: guard test
```sh
guard test web-login /var/log/nginx/access.log.1
guard test ssh # a journal watch: its last 5000 entries
zcat /var/log/nginx/access.log.2.gz | guard test web-login -
```
It prints each matching line with its address, then the addresses that
would be banned. Nothing is banned. Lines from a file carry no time
Guard reads, so every match counts however far apart; the journal's
entries have times, and the watch's window is used.