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:
commit
d06bf8d531
47 files changed
+4614
No files matched your search
+23
@@ -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.
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user