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

+3
View File
@@ -0,0 +1,3 @@
/bin/
/dist/
/guard
+59
View File
@@ -0,0 +1,59 @@
# Working on Guard
Notes for coding agents (and people) changing this repository.
Guard watches logs and bans misbehaving addresses in Housecarl's ban
lists (`../housecarl`, its docs/bans.md). It is the fourth of a set with
Jarl (`../jarl`), Steward (`../steward`) and Housecarl, and copies their
ways: Go, `CGO_ENABLED=0`, one YAML file, the same release process. It
has no web UI and no database: bans are shown by Housecarl.
## Layout
* `cmd/guard`: the one binary (cobra): `run` is the daemon, the rest are
tools.
* `internal/config`: guard.yaml, with `presets.yaml` and `example.yaml`
embedded. A watch's preset is resolved here (`Watch.resolve`).
* `internal/match`: patterns with `<IP>`; `Group` (IPv6 by prefix).
* `internal/source`: files (polled tail -F), the journal (journalctl
subprocess, `GUARD_JOURNALCTL` for tests), Housecarl's log.
* `internal/bounty`: per-watch counts within a window.
* `internal/daemon`: lines → matches → bounties → bans, the retry queue,
the status socket, `never_ban`.
* `internal/housecarl`: the API client (ban lists, bans, the log).
* `internal/state`: the JSON state file.
## Ground rules
* **Never trust a client's text for the address.** Preset patterns put
`<IP>` where the program writes the address, after anything a client
chose, anchored at the line's end where the format allows. Every
preset has sample lines in `testdata/presets/NAME.txt` (the address
to find, or `-`), including an attempt to plant an address;
`TestPresets` runs them.
* **No timestamp parsing.** Lines count when read (the journal's and
Housecarl's carry their own time).
* **Never ban on spoofable evidence by default**: UDP sources can be
faked (`housecarl-drops` is TCP unless told otherwise; no preset for
BIND's UDP refusals).
* **Every user-visible change updates `CHANGELOG.md`** under
`## [Unreleased]`, and the matching `docs/` page.
* `make test` runs `go vet`, a `gofmt -l` check and the tests.
## Testing
```sh
make test
make e2e # with ../housecarl's lab: needs Docker (DOCKER_HOST=unix:///var/run/docker.sock here)
```
## Git and releases
As in Housecarl: commit only when asked, never push; branches
`dev-X.Y.Z`; a release is a commit "Release X" setting `VERSION`, with
the annotated tag `vX` ("Guard X").
## Shell hygiene
Never kill processes by pattern (`pkill -f`): the pattern matches the
shell running it. Find the PID and kill that.
+28
View File
@@ -0,0 +1,28 @@
# Changelog
All notable changes to Guard are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- First version. Guard watches log files (followed through rotation and
truncation) and the systemd journal (by unit and syslog identifier),
matches lines with patterns holding one `<IP>`, counts each address's
bounty within a window, and bans an address that reaches a watch's
hits in a Housecarl ban list (Housecarl 0.2): of every port, or of the
watch's protocol and ports, for a time that grows for repeat
offenders. IPv6 addresses are counted and banned by the ban list's
prefix (/64). Never banned: `never_ban` (addresses, prefixes, host
names), loopback, link-local, multicast; Housecarl adds its own. Bans
wait while Housecarl can't be reached, across restarts.
- Presets: `sshd`, `jarl`, `housecarl`, `steward`, `bind-xfr`,
`postfix-sasl`, `dovecot`, `nginx-auth`, `housecarl-drops`
(Housecarl's log of dropped packets, TCP only by default).
- Commands: `guard run`, `check`, `config [--example]`, `presets`,
`test` (a watch's patterns over a log), `status` (through a local
socket), `bans`, `ban`, `unban`, `version`.
- A Debian package with a sandboxed systemd unit, as the `guard` user in
the `adm` and `systemd-journal` groups.
+8
View File
@@ -0,0 +1,8 @@
@AGENTS.md
## Claude Code specifics
- End commit messages with a `Co-Authored-By:` trailer naming the model,
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Keep temporary scripts and test servers' work directories in the
session scratchpad, not in the repo.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 abrown38
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+85
View File
@@ -0,0 +1,85 @@
# Guard build.
#
# make build bin/guard (and its bash completion)
# make test vet, gofmt check and unit tests
# make e2e Guard and Housecarl together, with real traffic (scripts/e2e.sh; needs ../housecarl and Docker)
# sudo make install install the binary, completion, systemd unit and sysusers file,
# and an example /etc/guard/guard.yaml
# make deb the Debian package in dist/ (DEB_ARCH=arm64 for another architecture)
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
DESCRIBE ?= $(shell git describe --tags --long --match 'v[0-9]*' --abbrev=12 HEAD 2>/dev/null)
LDFLAGS := -s -w -X guard/internal/version.Version=$(VERSION) -X guard/internal/version.Tag=$(RELEASE_TAG) \
-X guard/internal/version.Describe=$(DESCRIBE)
GOFLAGS := -trimpath
export CGO_ENABLED := 0
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
DOCDIR ?= $(PREFIX)/share/doc/guard
BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions
UNITDIR ?= /etc/systemd/system
SYSUSERSDIR ?= /usr/lib/sysusers.d
DESTDIR ?=
GUARD_BIN ?= bin/guard
DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH)
DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \
m=; git diff --quiet HEAD 2>/dev/null || m=.m$$(date -u +%Y%m%d%H%M%S); \
echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/")
DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION))))
.PHONY: all build completion test e2e install install-files uninstall deb clean
all: build
build:
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/guard ./cmd/guard
$(MAKE) --no-print-directory completion
completion:
mkdir -p bin/completion
bin/guard completion bash > bin/completion/guard
test:
go vet ./...
@test -z "$$(gofmt -l .)" || { gofmt -l .; echo "gofmt: the files above need formatting"; exit 1; }
go test ./...
e2e:
scripts/e2e.sh
install: build install-files
@if [ ! -e $(DESTDIR)/etc/guard/guard.yaml ]; then \
install -d -m 0750 $(DESTDIR)/etc/guard && \
bin/guard config --example > $(DESTDIR)/etc/guard/guard.yaml && chmod 0640 $(DESTDIR)/etc/guard/guard.yaml && \
echo "wrote $(DESTDIR)/etc/guard/guard.yaml"; \
fi
@echo
@echo "Next (as root): systemd-sysusers guard.conf && chgrp guard /etc/guard /etc/guard/guard.yaml && systemctl daemon-reload"
@echo "Then follow docs/install.md (a ban list and token in Housecarl, guard check, systemctl enable --now guard)."
# Everything but /etc/guard/guard.yaml (shared by install and deb).
install-files:
install -D -m 0755 $(GUARD_BIN) $(DESTDIR)$(BINDIR)/guard
install -D -m 0644 bin/completion/guard $(DESTDIR)$(BASHCOMPDIR)/guard
install -D -m 0644 deploy/systemd/guard.service $(DESTDIR)$(UNITDIR)/guard.service
sed -i 's#/usr/local/bin/guard#$(BINDIR)/guard#; s#/usr/local/share/doc/guard#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/guard.service
install -D -m 0644 deploy/sysusers.d/guard.conf $(DESTDIR)$(SYSUSERSDIR)/guard.conf
install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md
install -D -m 0644 LICENSE $(DESTDIR)$(DOCDIR)/LICENSE
install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md
install -d $(DESTDIR)$(DOCDIR)/docs
install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/
uninstall:
rm -f $(DESTDIR)$(BINDIR)/guard $(DESTDIR)$(BASHCOMPDIR)/guard $(DESTDIR)$(UNITDIR)/guard.service $(DESTDIR)$(SYSUSERSDIR)/guard.conf
rm -rf $(DESTDIR)$(DOCDIR)
@echo "Kept /etc/guard and /var/lib/guard."
deb: build
DEB_ARCH=$(DEB_ARCH) DEB_VERSION=$(DEB_VERSION) VERSION=$(VERSION) LDFLAGS="$(LDFLAGS)" GOFLAGS="$(GOFLAGS)" scripts/mkdeb.sh
clean:
rm -rf bin dist
+67
View File
@@ -0,0 +1,67 @@
# Guard
Guard watches logs and bans the addresses that misbehave in them, at the
firewall. It reads log files and the systemd journal, counts each
address's **bounty** (its matches within a while), and once that reaches
a watch's limit bans the address in a ban list of
[Housecarl](https://gitea.adbraun.lol/adam/housecarl), the firewall
manager. The ban list says what a banned address is kept from: the
firewall itself, some hosts behind it, or everything. Guard doesn't need
to know anything about the firewall.
It is the fourth of a set with Jarl (BIND), Steward (DHCP) and Housecarl
(nftables), and works like fail2ban, with one easy file instead of jails
and filters spread over many:
```yaml
housecarl:
url: https://router.lan:8070
token_file: /etc/guard/token
list: web
watch:
- name: ssh
preset: sshd # OpenSSH, from the journal
ban: {protocol: tcp, port: 22}
- name: web-login
file: /var/log/nginx/access.log
match: ['^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 ']
hits: 10
ban: {time: 6h}
```
* **One pattern placeholder, `<IP>`.** It matches IPv4 and IPv6
(bracketed, IPv4-mapped); what only looks like an address is never
taken for one.
* **No date formats.** Guard follows logs as they are written, and
counts each line at the time it reads it (the journal's entries have
their own time).
* **Files and the journal**, the journal by unit or syslog identifier
(`sshd-session` on Debian 13).
* **Presets** for OpenSSH, Postfix, Dovecot, nginx, BIND zone transfers,
Jarl, Housecarl and Steward, and Housecarl's own log of dropped
packets, for scans ([docs/presets.md](docs/presets.md)).
* **Repeat offenders** get longer bans: each earlier ban doubles the
time, up to a week.
* **Never banned:** your own addresses, from Guard's `never_ban`, and
from Housecarl, which never bans the inside networks, the firewall's
own addresses or the machine asking.
* **IPv6 by /64**, as the ban list says: one customer usually has a
whole /64 and moves around in it.
* **Housecarl away?** Bans wait and are sent once it answers.
* **`guard test`** shows what a watch's patterns find in a log, and whom
they would ban, before you trust it.
## Documentation
* [install.md](docs/install.md): installing, the ban list and token in Housecarl, first steps
* [configuration.md](docs/configuration.md): `/etc/guard/guard.yaml`
* [presets.md](docs/presets.md): the presets, and writing your own patterns
* [cli.md](docs/cli.md): the `guard` command
* [CHANGELOG.md](CHANGELOG.md)
Housecarl's side: its [docs/bans.md](https://gitea.adbraun.lol/adam/housecarl/src/branch/main/docs/bans.md).
## Status
0.1.0 in development. Go, `CGO_ENABLED=0`, Linux. MIT licence.
+1
View File
@@ -0,0 +1 @@
0.1.0-dev
+690
View File
@@ -0,0 +1,690 @@
// Command guard watches logs and bans the addresses that misbehave in
// them, at the firewall, through Housecarl's ban lists. One binary: the
// daemon (guard run) and the commands to check, test and see it.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"guard/internal/config"
"guard/internal/daemon"
"guard/internal/housecarl"
"guard/internal/match"
"guard/internal/source"
"guard/internal/state"
"guard/internal/version"
)
var (
flagConfig string
flagJSON bool
)
func main() {
root := &cobra.Command{
Use: "guard",
Short: "Guard watches logs and bans misbehaving addresses at the firewall (Housecarl)",
Long: `Guard watches log files and the systemd journal for misdeeds (failed
logins, scans), counts each address's bounty, and once it reaches a
watch's hits bans the address in a Housecarl ban list: the firewall then
keeps it from this machine (or from what the list says).
guard check check guard.yaml and reach Housecarl
guard test ssh /var/log/auth.log what a watch's patterns find in a log
guard status the watches, their highest bounties, recent bans
Exit codes: 0 ok, 1 error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
root.PersistentFlags().StringVarP(&flagConfig, "config", "c", config.DefaultPath, "configuration file")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "print JSON instead of tables")
root.AddCommand(runCmd(), checkCmd(), configCmd(), presetsCmd(), testCmd(), statusCmd(), bansCmd(), banCmd(), unbanCmd(), versionCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "guard:", err)
os.Exit(1)
}
}
func load() (*config.Config, error) { return config.Load(flagConfig) }
// client makes the Housecarl client of a configuration.
func client(cfg *config.Config) (*housecarl.Client, error) {
h := cfg.Housecarl
tok := h.Token
if h.TokenFile != "" {
b, err := os.ReadFile(h.TokenFile)
if err != nil {
return nil, fmt.Errorf("the token: %w", err)
}
tok = strings.TrimSpace(string(b))
}
return housecarl.New(housecarl.Options{URL: h.URL, Socket: h.Socket, Token: tok, CACert: h.CACert, Insecure: h.Insecure})
}
func logger(cfg *config.Config) *slog.Logger {
var lv slog.Level
lv.UnmarshalText([]byte(cfg.Log.Level))
opts := &slog.HandlerOptions{Level: lv}
if cfg.Log.Format == "json" {
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}
return slog.New(slog.NewTextHandler(os.Stderr, opts))
}
func table(f func(w io.Writer)) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
f(w)
w.Flush()
}
func printJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// ctx is the commands' context: each request has the client's timeout.
func ctx() context.Context { return context.Background() }
// ---- run
func runCmd() *cobra.Command {
return &cobra.Command{
Use: "run", Short: "Watch the logs and ban (the daemon; systemd runs it)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
log := logger(cfg)
hc, err := client(cfg)
if err != nil {
return err
}
st, err := state.Load(cfg.State)
if err != nil {
return fmt.Errorf("the state %s: %w", cfg.State, err)
}
d, err := daemon.New(cfg, hc, hc, st, log)
if err != nil {
return err
}
c, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Info("starting", "version", version.String(), "config", flagConfig)
return d.Run(c)
},
}
}
// ---- check
func checkCmd() *cobra.Command {
return &cobra.Command{
Use: "check", Short: "Check the configuration, and that Housecarl answers", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
fmt.Printf("%s: ok\n", flagConfig)
lists := map[string]bool{}
drops := false
table(func(w io.Writer) {
fmt.Fprintln(w, "WATCH\tSOURCE\tPATTERNS\tBAN AFTER\tLIST\tPORTS")
for _, x := range cfg.Watch {
if x.Disabled {
fmt.Fprintf(w, "%s\t(disabled)\t\t\t\t\n", x.Name)
continue
}
lists[x.Ban.List] = true
drops = drops || x.Kind() == config.SourceHousecarlDrops
fmt.Fprintf(w, "%s\t%s\t%d\t%d in %s\t%s\t%s\n", x.Name, daemon.Describe(x), len(x.Match), x.Hits, x.Within, x.Ban.List, ports(x.Ban.Protocol, x.Ban.Port))
}
})
hc, err := client(cfg)
if err != nil {
return err
}
bad := false
for l := range lists {
bl, err := hc.BanList(ctx(), l)
switch {
case err != nil:
fmt.Printf("ban list %s: %v\n", l, err)
bad = true
case bl.Disabled:
fmt.Printf("ban list %s: disabled in Housecarl (bans aren't in force)\n", l)
default:
fmt.Printf("ban list %s: ok (scope %s, IPv6 banned by /%d)\n", l, bl.Scope, bl.V6Group())
}
}
if drops {
if _, err := hc.Log(ctx(), time.Now()); err != nil {
fmt.Printf("Housecarl's log (housecarl-drops): %v (the token needs log:read)\n", err)
bad = true
} else {
fmt.Println("Housecarl's log (housecarl-drops): ok")
}
}
if bad {
return errors.New("Housecarl isn't ready for these watches")
}
return nil
},
}
}
func ports(proto string, p []string) string {
if len(p) == 0 {
return "all"
}
if proto == "" {
return strings.Join(p, ",")
}
return strings.ToLower(proto) + "/" + strings.Join(p, ",")
}
// ---- config
func configCmd() *cobra.Command {
var example bool
cmd := &cobra.Command{
Use: "config", Short: "Print the configuration as read (--example: a commented one)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if example {
fmt.Print(config.Example())
return nil
}
cfg, err := load()
if err != nil {
return err
}
return printJSON(cfg)
},
}
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
return cmd
}
// ---- presets
func presetsCmd() *cobra.Command {
return &cobra.Command{
Use: "presets [PRESET]", Short: "List the presets, or show one's patterns", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ps := config.Presets()
if len(args) == 1 {
p, ok := ps[args[0]]
if !ok {
return fmt.Errorf("there is no preset %q", args[0])
}
if flagJSON {
return printJSON(p)
}
w := &config.Watch{Preset: args[0], File: p.File, Journal: p.Journal, Source: p.Source}
fmt.Printf("%s: %s\nSource: %s\n", args[0], p.Description, daemon.Describe(w))
for _, m := range p.Match {
fmt.Printf("Match: %s\n", m)
}
for _, m := range p.Ignore {
fmt.Printf("Ignore: %s\n", m)
}
return nil
}
var names []string
for n := range ps {
names = append(names, n)
}
sort.Strings(names)
if flagJSON {
return printJSON(ps)
}
table(func(w io.Writer) {
fmt.Fprintln(w, "PRESET\tSOURCE\tWHAT")
for _, n := range names {
p := ps[n]
fmt.Fprintf(w, "%s\t%s\t%s\n", n, daemon.Describe(&config.Watch{File: p.File, Journal: p.Journal, Source: p.Source, Protocol: "tcp"}), p.Description)
}
})
return nil
},
}
}
// ---- test
func testCmd() *cobra.Command {
var lines int
var all bool
cmd := &cobra.Command{
Use: "test WATCH [FILE|-]",
Short: "Run a watch's patterns over a log: what they match, whom they'd ban",
Long: `Runs a watch's patterns over a log, from its start: a file, standard
input (-), or for a journal watch without FILE its last --lines entries.
It prints each matching line's address, and the addresses that would be
banned. Nothing is banned.
A file's lines carry no time Guard reads, so for a file every match
counts, however far apart; the journal's entries have their times, and
the watch's window (within) is used.`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
var w *config.Watch
for _, x := range cfg.Watch {
if strings.EqualFold(x.Name, args[0]) {
w = x
}
}
if w == nil {
return fmt.Errorf("there is no watch %q in %s", args[0], flagConfig)
}
if w.Kind() == config.SourceHousecarlDrops {
return errors.New("a housecarl-drops watch has no patterns to test: every dropped packet counts")
}
m, err := match.Compile(w.Match, w.Ignore)
if err != nil {
return err
}
var in []source.Line
timed := false
switch {
case len(args) == 2:
in, err = readLines(args[1])
case w.Kind() == "journal":
in, err = readJournal(w, lines)
timed = true
default:
in, err = readLines(w.File)
}
if err != nil {
return err
}
never := daemon.NewNeverBan(cfg.NeverBan, nil)
type hit struct {
count int
ban bool
}
hits := map[string]*hit{}
order := []string{}
tr := map[string][]time.Time{}
matched := 0
for _, l := range in {
a, ok := m.Match(l.Text)
if !ok {
continue
}
matched++
why := never.Why(a)
if all || matched <= 200 {
note := ""
if why != "" {
note = " (never banned: " + why + ")"
}
fmt.Printf("%-39s %s%s\n", a, clip(l.Text, 160), note)
}
if why != "" {
continue
}
g := match.Group(a, 64).String()
h := hits[g]
if h == nil {
h = &hit{}
hits[g] = h
order = append(order, g)
}
h.count++
if timed {
ts := append(tr[g], l.Time)
for len(ts) > 0 && ts[0].Before(l.Time.Add(-w.Within.D())) {
ts = ts[1:]
}
tr[g] = ts
h.ban = h.ban || len(ts) >= w.Hits
} else {
h.ban = h.ban || h.count >= w.Hits
}
}
if matched > 200 && !all {
fmt.Printf("… %d more matching lines (--all shows them)\n", matched-200)
}
fmt.Printf("\n%d lines, %d matched, %d addresses (IPv6 by /64)\n", len(in), matched, len(order))
sort.SliceStable(order, func(i, j int) bool { return hits[order[i]].count > hits[order[j]].count })
banned := 0
for _, g := range order {
if hits[g].ban {
banned++
}
}
if banned == 0 {
fmt.Printf("nobody would be banned (%d hits within %s)\n", w.Hits, w.Within)
return nil
}
window := "within " + w.Within.String()
if !timed {
window = "in all (a file's lines have no time)"
}
fmt.Printf("would ban %d (%d hits %s):\n", banned, w.Hits, window)
for _, g := range order {
if hits[g].ban {
fmt.Printf(" %-39s %d matches\n", g, hits[g].count)
}
}
return nil
},
}
cmd.Flags().IntVarP(&lines, "lines", "n", 5000, "a journal watch without FILE: the last this many entries")
cmd.Flags().BoolVar(&all, "all", false, "print every matching line, not the first 200")
return cmd
}
func readLines(path string) ([]source.Line, error) {
var r io.Reader = os.Stdin
if path != "-" {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
r = f
}
var out []source.Line
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
out = append(out, source.Line{Text: sc.Text()})
}
return out, sc.Err()
}
func readJournal(w *config.Watch, n int) ([]source.Line, error) {
c := exec.Command(source.JournalCommand(), source.JournalArgs(w.Journal.Unit, w.Journal.Identifier, false, "", n)...)
c.Stderr = os.Stderr
outp, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
ch := make(chan source.Line, 64)
done := make(chan []source.Line)
go func() {
var out []source.Line
for l := range ch {
out = append(out, l)
}
done <- out
}()
source.ReadJournal(context.Background(), outp, ch, "")
close(ch)
lines := <-done
if err := c.Wait(); err != nil {
return nil, fmt.Errorf("journalctl: %w (Guard's user needs the systemd-journal group)", err)
}
return lines, nil
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
// ---- status
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "The running Guard: its watches, highest bounties, recent bans", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
hc := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{DialContext: func(c context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(c, "unix", cfg.Socket)
}}}
resp, err := hc.Get("http://guard/status")
if err != nil {
return fmt.Errorf("Guard isn't running, or %s can't be reached (as root, or the guard user): %w", cfg.Socket, err)
}
defer resp.Body.Close()
var s daemon.Status
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return err
}
if flagJSON {
return printJSON(s)
}
fmt.Printf("Running since %s (%s)\n\n", s.Started.Local().Format("2006-01-02 15:04"), config.FormatDuration(time.Since(s.Started).Round(time.Second)))
table(func(w io.Writer) {
fmt.Fprintln(w, "WATCH\tSOURCE\tLINES\tMATCHES\tBANS\tBAN AFTER\tHIGHEST BOUNTIES")
for _, x := range s.Watches {
var top []string
for i, b := range x.Top {
if i == 3 {
break
}
top = append(top, fmt.Sprintf("%s %d/%d", b.Address, b.Bounty, x.Hits))
}
fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%d\t%d in %s\t%s\n", x.Name, x.Source, x.Lines, x.Matches, x.Bans, x.Hits, x.Within, strings.Join(top, ", "))
}
})
if len(s.Pending) > 0 {
fmt.Printf("\n%d bans wait for Housecarl\n", len(s.Pending))
}
if len(s.Recent) > 0 {
fmt.Println("\nRecent bans:")
table(func(w io.Writer) {
fmt.Fprintln(w, "TIME\tWATCH\tADDRESS\tLIST\tPORTS\tFOR\tRESULT")
for i, b := range s.Recent {
if i == 20 {
break
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Time.Local().Format("01-02 15:04"), b.Watch, b.Address, b.List,
ports(b.Protocol, splitComma(b.Ports)), config.FormatDuration(time.Duration(b.Seconds)*time.Second), clip(b.Result, 60))
}
})
}
return nil
},
}
}
func splitComma(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
// ---- bans in Housecarl
func listFlag(cfg *config.Config, list string) (string, error) {
if list != "" {
return list, nil
}
if cfg.Housecarl.List == "" {
return "", errors.New("which ban list? Give --list, or set housecarl.list")
}
return cfg.Housecarl.List, nil
}
func bansCmd() *cobra.Command {
var list string
cmd := &cobra.Command{
Use: "bans", Short: "The active bans in Housecarl (of the ban list, or --list)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
hc, err := client(cfg)
if err != nil {
return err
}
bans, err := hc.Bans(ctx(), l)
if err != nil {
return err
}
if flagJSON {
return printJSON(bans)
}
table(func(w io.Writer) {
fmt.Fprintln(w, "ID\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE")
for _, b := range bans {
port := "all"
if b.Protocol != "" {
port = b.Protocol + "/" + strconv.Itoa(b.Port)
}
ends := "never"
if b.Timeout > 0 {
ends = config.FormatDuration(time.Duration(b.Timeout) * time.Second)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", b.ID, b.Address, port, ends, b.Who, b.Source)
}
})
return nil
},
}
cmd.Flags().StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
return cmd
}
func banCmd() *cobra.Command {
var list, t, reason, proto string
var port []string
cmd := &cobra.Command{
Use: "ban ADDRESS", Short: "Ban an address by hand", Args: cobra.ExactArgs(1),
Long: `Bans an address in Housecarl, as a watch would: every port, or with
--protocol and --port some (numbers or service names).
guard ban 203.0.113.9 --time 1d
guard ban 203.0.113.9 --protocol tcp --port 22`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
if _, err := netip.ParseAddr(strings.Trim(args[0], "[]")); err != nil {
return fmt.Errorf("%q isn't an address", args[0])
}
in := housecarl.BanInput{Address: args[0], Protocol: proto, Ports: strings.Join(port, ","), Source: "guard (by hand)", Reason: reason}
if t != "" {
d, err := config.ParseDuration(t)
if err != nil {
return err
}
in.Time = int(d / time.Second)
}
hc, err := client(cfg)
if err != nil {
return err
}
res, err := hc.Ban(ctx(), l, in)
if err != nil {
return err
}
if flagJSON {
return printJSON(res)
}
for _, b := range res.Bans {
what := "banned"
if b.Existing {
what = "renewed"
}
port := "every port"
if b.Protocol != "" {
port = b.Protocol + "/" + strconv.Itoa(b.Port)
}
fmt.Printf("%s %s, %s, in %s, for %s\n", what, b.Address, port, b.List, config.FormatDuration(time.Duration(b.Timeout)*time.Second))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
f.StringVar(&proto, "protocol", "", "tcp or udp (with --port)")
f.StringArrayVar(&port, "port", nil, "a port or service (repeat; default: every port)")
f.StringVar(&t, "time", "", "how long (1h, 1d; default: the list's)")
f.StringVar(&reason, "reason", "", "why, for the record")
return cmd
}
func unbanCmd() *cobra.Command {
var list string
cmd := &cobra.Command{
Use: "unban ADDRESS", Short: "Lift an address's bans in the ban list", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
hc, err := client(cfg)
if err != nil {
return err
}
n, err := hc.Unban(ctx(), l, args[0])
if err != nil {
return err
}
fmt.Printf("%d bans lifted\n", n)
return nil
},
}
cmd.Flags().StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
return cmd
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
if flagJSON {
printJSON(version.Info())
return
}
fmt.Println("guard", version.String())
},
}
}
+18
View File
@@ -0,0 +1,18 @@
Package: guard
Version: @VERSION@
Architecture: @ARCH@
Maintainer: @MAINTAINER@
Installed-Size: @SIZE@
Depends: systemd
Section: net
Priority: optional
Description: bans addresses that misbehave in the logs, at the firewall
Guard watches log files and the systemd journal for failed logins,
scans and other misdeeds, counts each address's matches, and bans an
address that reaches a watch's limit in a ban list of Housecarl, the
firewall manager: the firewall then keeps it from this machine (or from
what the list says). One YAML file configures it; presets cover OpenSSH,
Postfix, Dovecot, nginx, BIND, Jarl, Housecarl and Steward.
.
guard isn't started on a first install: see
/usr/share/doc/guard/docs/install.md.
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Guard: runs after the package's files are in place.
set -e
if [ "$1" = configure ]; then
systemd-sysusers guard.conf
install -d -m 0750 -o root -g guard /etc/guard
# An example configuration on the first install; an existing one is
# never touched.
if [ ! -e /etc/guard/guard.yaml ]; then
/usr/bin/guard config --example > /etc/guard/guard.yaml.dpkg-new
chown root:guard /etc/guard/guard.yaml.dpkg-new
chmod 0640 /etc/guard/guard.yaml.dpkg-new
mv /etc/guard/guard.yaml.dpkg-new /etc/guard/guard.yaml
fi
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
# An upgrade restarts a running guard; a first install starts
# nothing (it needs Housecarl's ban list and token first).
if [ -n "$2" ] && systemctl is-active --quiet guard.service; then
systemctl restart guard.service || true
fi
fi
fi
exit 0
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Guard: runs after the package's files are removed.
set -e
case "$1" in
remove|purge)
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
fi
;;
esac
# The bans Guard made stay in Housecarl until they end.
if [ "$1" = purge ]; then
echo "guard: kept /etc/guard and /var/lib/guard; remove them by hand if unused."
fi
exit 0
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Guard: runs before the package's files are removed (also on upgrades).
set -e
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
systemctl stop guard.service 2>/dev/null || true
systemctl disable guard.service 2>/dev/null || true
fi
exit 0
+55
View File
@@ -0,0 +1,55 @@
# Guard: watches logs and bans misbehaving addresses at the firewall
# (Housecarl's ban lists). Installed as /etc/systemd/system/guard.service
# (see docs/install.md).
[Unit]
Description=Guard: bans addresses that misbehave in the logs, at the firewall
Documentation=file:///usr/local/share/doc/guard/README.md
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/guard --config /etc/guard/guard.yaml run
Restart=on-failure
RestartSec=5s
User=guard
Group=guard
# Reading logs: /var/log's files (adm) and the journal (systemd-journal).
# On the firewall, add housecarl to use Housecarl's local socket.
SupplementaryGroups=adm systemd-journal
UMask=0027
# /var/lib/guard: the state (repeat offenders, bans waiting for
# Housecarl). /run/guard: the socket guard status asks.
StateDirectory=guard
StateDirectoryMode=0750
ConfigurationDirectory=guard
ConfigurationDirectoryMode=0750
RuntimeDirectory=guard
RuntimeDirectoryMode=0750
# Sandboxing: Guard only reads logs and talks to Housecarl.
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
# Guard's system user (systemd-sysusers): it reads logs and talks to
# Housecarl, nothing more.
u guard - "Guard log watcher" /var/lib/guard /usr/sbin/nologin
+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.
+13
View File
@@ -0,0 +1,13 @@
module guard
go 1.27.1
require (
github.com/spf13/cobra v1.10.2
go.yaml.in/yaml/v3 v3.0.5
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+12
View File
@@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+120
View File
@@ -0,0 +1,120 @@
// Package bounty keeps each address's bounty in a watch: its matches
// within the watch's window. An address whose bounty reaches the watch's
// hits is banned, and starts again from nothing.
package bounty
import (
"net/netip"
"sort"
"sync"
"time"
)
// MaxTracked is the most addresses a watch keeps a bounty for; past it,
// the ones seen longest ago are forgotten.
const MaxTracked = 100000
// Tracker counts one watch's matches per address (or IPv6 prefix).
type Tracker struct {
Hits int
Within time.Duration
mu sync.Mutex
seen map[netip.Prefix][]time.Time
}
// New makes a Tracker.
func New(hits int, within time.Duration) *Tracker {
return &Tracker{Hits: hits, Within: within, seen: map[netip.Prefix][]time.Time{}}
}
// Add counts a match of p at a time. It returns p's bounty, and whether
// it reached the hits (p's bounty is then cleared).
func (t *Tracker) Add(p netip.Prefix, at time.Time) (int, bool) {
t.mu.Lock()
defer t.mu.Unlock()
ts := keep(t.seen[p], at.Add(-t.Within))
ts = append(ts, at)
if len(ts) >= t.Hits {
delete(t.seen, p)
return len(ts), true
}
t.seen[p] = ts
if len(t.seen) > MaxTracked {
t.prune(at)
}
return len(ts), false
}
// keep drops the times before from (times are in order).
func keep(ts []time.Time, from time.Time) []time.Time {
i := 0
for i < len(ts) && ts[i].Before(from) {
i++
}
return ts[i:]
}
// prune forgets what is out of the window, then, if still too many, the
// addresses seen longest ago.
func (t *Tracker) prune(now time.Time) {
for p, ts := range t.seen {
if ts = keep(ts, now.Add(-t.Within)); len(ts) == 0 {
delete(t.seen, p)
} else {
t.seen[p] = ts
}
}
if len(t.seen) <= MaxTracked {
return
}
type last struct {
p netip.Prefix
t time.Time
}
all := make([]last, 0, len(t.seen))
for p, ts := range t.seen {
all = append(all, last{p, ts[len(ts)-1]})
}
sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) })
for _, x := range all[:len(all)-MaxTracked*9/10] {
delete(t.seen, x.p)
}
}
// Bounty is an address's bounty.
type Bounty struct {
Address string `json:"address"`
Bounty int `json:"bounty"`
Last time.Time `json:"last"`
}
// Top returns the n highest bounties now (within the window), highest
// first.
func (t *Tracker) Top(n int, now time.Time) []Bounty {
t.mu.Lock()
defer t.mu.Unlock()
var out []Bounty
for p, ts := range t.seen {
if ts = keep(ts, now.Add(-t.Within)); len(ts) > 0 {
out = append(out, Bounty{Address: address(p), Bounty: len(ts), Last: ts[len(ts)-1]})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Bounty != out[j].Bounty {
return out[i].Bounty > out[j].Bounty
}
return out[i].Last.After(out[j].Last)
})
if len(out) > n {
out = out[:n]
}
return out
}
func address(p netip.Prefix) string {
if p.IsSingleIP() {
return p.Addr().String()
}
return p.String()
}
+30
View File
@@ -0,0 +1,30 @@
package bounty
import (
"net/netip"
"testing"
"time"
)
func TestTracker(t *testing.T) {
tr := New(3, time.Minute)
p := netip.MustParsePrefix("203.0.113.9/32")
t0 := time.Now()
if n, r := tr.Add(p, t0); n != 1 || r {
t.Errorf("first: %d %v", n, r)
}
// Two minutes later the first is out of the window.
tr.Add(p, t0.Add(2*time.Minute))
if n, r := tr.Add(p, t0.Add(2*time.Minute+time.Second)); n != 2 || r {
t.Errorf("window: %d %v", n, r)
}
if top := tr.Top(5, t0.Add(2*time.Minute+time.Second)); len(top) != 1 || top[0].Bounty != 2 || top[0].Address != "203.0.113.9" {
t.Errorf("top %+v", top)
}
if n, r := tr.Add(p, t0.Add(2*time.Minute+2*time.Second)); n != 3 || !r {
t.Errorf("reached: %d %v", n, r)
}
if top := tr.Top(5, t0.Add(3*time.Minute)); len(top) != 0 {
t.Errorf("after the ban the bounty starts again: %+v", top)
}
}
+442
View File
@@ -0,0 +1,442 @@
// Package config reads Guard's configuration: one YAML file,
// /etc/guard/guard.yaml (docs/configuration.md). Unknown settings are an
// error; everything not given has a default.
package config
import (
"bytes"
"errors"
"fmt"
"io"
"net/netip"
"os"
"regexp"
"strconv"
"strings"
"time"
"go.yaml.in/yaml/v3"
"guard/internal/match"
)
// DefaultPath is where the configuration is read from.
const DefaultPath = "/etc/guard/guard.yaml"
// Config is guard.yaml.
type Config struct {
Housecarl Housecarl `yaml:"housecarl"`
// NeverBan are addresses, prefixes and host names never banned.
NeverBan []string `yaml:"never_ban"`
Defaults Defaults `yaml:"defaults"`
Watch []*Watch `yaml:"watch"`
// State is where Guard keeps what must outlive a restart: repeat
// offenders, recent bans, bans waiting for Housecarl.
State string `yaml:"state"`
// Socket is the local socket 'guard status' asks.
Socket string `yaml:"socket"`
Log Log `yaml:"log"`
}
// Housecarl is how Guard reaches Housecarl.
type Housecarl struct {
// URL is Housecarl's API ("https://router.lan:8070"), or Socket its
// local socket on the firewall itself.
URL string `yaml:"url"`
Socket string `yaml:"socket"`
// TokenFile (or Token) is the API token: one for the ban lists Guard
// bans into (bans:write:LIST).
TokenFile string `yaml:"token_file"`
Token string `yaml:"token"`
// CACert trusts a private CA for Housecarl's certificate; Insecure
// skips checking it.
CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"`
// List is the ban list a watch bans into unless it names another.
List string `yaml:"list"`
}
// Defaults are the watches' settings unless they say otherwise.
type Defaults struct {
// Hits matches of an address within Within ban it.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
// Ban is how long a first ban lasts.
Ban Duration `yaml:"ban"`
Repeat Repeat `yaml:"repeat"`
}
// Repeat makes repeat offenders' bans longer: each earlier ban within
// Forget multiplies the time by Factor, up to Max.
type Repeat struct {
Factor float64 `yaml:"factor"`
Max Duration `yaml:"max"`
Forget Duration `yaml:"forget"`
}
// Log is Guard's own logging.
type Log struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// Watch is one log to watch: where its lines come from, what in them is
// a misdeed, and what a ban is.
type Watch struct {
Name string `yaml:"name"`
// Preset names built-in patterns and their usual source
// (docs/presets.md); the watch's own match lines are added.
Preset string `yaml:"preset"`
// One source: a file (or glob), the journal, or Housecarl's log of
// dropped packets (source: housecarl-drops).
File string `yaml:"file"`
Journal *Journal `yaml:"journal"`
Source string `yaml:"source"`
// Match are regular expressions with one <IP>; Ignore lines are
// skipped even when they match.
Match []string `yaml:"match"`
Ignore []string `yaml:"ignore"`
// Protocol limits housecarl-drops to a protocol (default tcp: UDP
// sources can be faked); Shadow counts shadow mode's would-be drops.
Protocol string `yaml:"protocol"`
Shadow bool `yaml:"shadow"`
// Hits and Within override the defaults.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
Ban Ban `yaml:"ban"`
// Disabled watches are skipped.
Disabled bool `yaml:"disabled"`
}
// Journal selects journal entries: by systemd unit and/or syslog
// identifier (each a name or a list; entries of any).
type Journal struct {
Unit Strings `yaml:"unit"`
Identifier Strings `yaml:"identifier"`
}
// Ban is what a watch's ban is: into which list, of which ports, for how
// long.
type Ban struct {
List string `yaml:"list"`
// Protocol and Port limit the ban (port numbers or service names);
// without them, every port.
Protocol string `yaml:"protocol"`
Port Strings `yaml:"port"`
Time Duration `yaml:"time"`
}
// Strings is a YAML scalar or list of scalars.
type Strings []string
// UnmarshalYAML reads "x", 22, or [x, 22].
func (s *Strings) UnmarshalYAML(n *yaml.Node) error {
switch n.Kind {
case yaml.ScalarNode:
*s = Strings{n.Value}
return nil
case yaml.SequenceNode:
var out []string
for _, c := range n.Content {
if c.Kind != yaml.ScalarNode {
return fmt.Errorf("line %d: give a name, or a list of names", c.Line)
}
out = append(out, c.Value)
}
*s = out
return nil
}
return fmt.Errorf("line %d: give a name, or a list of names", n.Line)
}
// Duration is a length of time: 30s, 10m, 1h, 1d, 1w, or combined
// (1d12h).
type Duration time.Duration
var durRe = regexp.MustCompile(`^(\d+[wdhms])+$`)
// ParseDuration reads a Duration.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if !durRe.MatchString(s) {
return 0, fmt.Errorf("%q isn't a length of time: write it as 30s, 10m, 1h, 1d or 1w", s)
}
var d time.Duration
unit := map[byte]time.Duration{'w': 7 * 24 * time.Hour, 'd': 24 * time.Hour, 'h': time.Hour, 'm': time.Minute, 's': time.Second}
for _, m := range regexp.MustCompile(`(\d+)([wdhms])`).FindAllStringSubmatch(s, -1) {
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, err
}
d += time.Duration(n) * unit[m[2][0]]
}
return d, nil
}
// UnmarshalYAML reads a Duration.
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
v, err := ParseDuration(n.Value)
if err != nil {
return fmt.Errorf("line %d: %v", n.Line, err)
}
*d = Duration(v)
return nil
}
// D is the time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// String writes a Duration the short way: 1h30m, 1w.
func (d Duration) String() string { return FormatDuration(time.Duration(d)) }
// FormatDuration writes a length of time the short way.
func FormatDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
var b strings.Builder
for _, u := range []struct {
d time.Duration
s string
}{{7 * 24 * time.Hour, "w"}, {24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
if d >= u.d {
fmt.Fprintf(&b, "%d%s", d/u.d, u.s)
d %= u.d
}
}
return b.String()
}
// Load reads and checks the configuration.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(data)
}
// Parse reads and checks a configuration.
func Parse(data []byte) (*Config, error) {
c := &Config{}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("guard.yaml: %w", err)
}
c.defaults()
if err := c.check(); err != nil {
return nil, err
}
return c, nil
}
func (c *Config) defaults() {
d := &c.Defaults
if d.Hits == 0 {
d.Hits = 5
}
if d.Within == 0 {
d.Within = Duration(10 * time.Minute)
}
if d.Ban == 0 {
d.Ban = Duration(time.Hour)
}
if d.Repeat.Factor == 0 {
d.Repeat.Factor = 2
}
if d.Repeat.Max == 0 {
d.Repeat.Max = Duration(7 * 24 * time.Hour)
}
if d.Repeat.Forget == 0 {
d.Repeat.Forget = Duration(7 * 24 * time.Hour)
}
if c.State == "" {
c.State = "/var/lib/guard/state.json"
}
if c.Socket == "" {
c.Socket = "/run/guard/guard.sock"
}
if c.Log.Level == "" {
c.Log.Level = "info"
}
if c.Log.Format == "" {
c.Log.Format = "text"
}
for _, w := range c.Watch {
if w.Hits == 0 {
w.Hits = d.Hits
}
if w.Within == 0 {
w.Within = d.Within
}
if w.Ban.Time == 0 {
w.Ban.Time = d.Ban
}
if w.Ban.List == "" {
w.Ban.List = c.Housecarl.List
}
}
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,47}$`)
// Source names besides files and the journal.
const SourceHousecarlDrops = "housecarl-drops"
func (c *Config) check() error {
var errs []string
add := func(format string, args ...any) { errs = append(errs, fmt.Sprintf(format, args...)) }
h := c.Housecarl
switch {
case h.URL == "" && h.Socket == "":
add("housecarl: give url (https://router.lan:8070) or socket (/run/housecarl/housecarl.sock)")
case h.URL != "" && h.Socket != "":
add("housecarl: give url or socket, not both")
case h.URL != "" && !strings.HasPrefix(h.URL, "http://") && !strings.HasPrefix(h.URL, "https://"):
add("housecarl.url %q: start it with https:// (or http://)", h.URL)
}
if h.Token != "" && h.TokenFile != "" {
add("housecarl: give token_file or token, not both")
}
for _, n := range c.NeverBan {
if _, err := netip.ParsePrefix(n); err == nil {
continue
}
if _, err := netip.ParseAddr(n); err == nil {
continue
}
if !hostRe.MatchString(n) {
add("never_ban: %q isn't an address, prefix or host name", n)
}
}
d := c.Defaults
if d.Hits < 1 {
add("defaults.hits is %d; use 1 or more", d.Hits)
}
if d.Repeat.Factor < 1 {
add("defaults.repeat.factor is %g; use 1 (bans don't grow) or more", d.Repeat.Factor)
}
if lv := strings.ToLower(c.Log.Level); lv != "debug" && lv != "info" && lv != "warn" && lv != "error" {
add("log.level %q: use debug, info, warn or error", c.Log.Level)
}
if c.Log.Format != "text" && c.Log.Format != "json" {
add("log.format %q: use text or json", c.Log.Format)
}
if len(c.Watch) == 0 {
add("watch: nothing to watch; add a watch (guard config --example shows some)")
}
seen := map[string]bool{}
for i, w := range c.Watch {
what := fmt.Sprintf("watch %d", i+1)
if w.Name != "" {
what = "watch " + w.Name
}
if !nameRe.MatchString(w.Name) {
add("%s: give it a name (letters, digits, - _ .; starting with a letter)", what)
} else if seen[strings.ToLower(w.Name)] {
add("%s: the name is used twice", what)
}
seen[strings.ToLower(w.Name)] = true
if err := w.resolve(); err != nil {
add("%s: %v", what, err)
continue
}
if w.Hits < 1 {
add("%s: hits is %d; use 1 or more", what, w.Hits)
}
if w.Ban.List == "" {
add("%s: which ban list? Set housecarl.list, or ban.list", what)
}
switch strings.ToLower(w.Ban.Protocol) {
case "", "tcp", "udp":
default:
add("%s: ban.protocol %q: use tcp, udp, or leave it out", what, w.Ban.Protocol)
}
if w.Ban.Protocol != "" && len(w.Ban.Port) == 0 {
add("%s: ban.protocol without ban.port: give the port, or leave both out to ban every port", what)
}
if _, err := match.Compile(w.Match, w.Ignore); err != nil {
add("%s: %v", what, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("guard.yaml:\n %s", strings.Join(errs, "\n "))
}
return nil
}
var hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`)
// Kind is a watch's kind of source: "file", "journal" or
// "housecarl-drops" (after resolve).
func (w *Watch) Kind() string {
switch {
case w.Source != "":
return w.Source
case w.Journal != nil:
return "journal"
}
return "file"
}
// resolve fills in a watch's preset: its source unless the watch gives
// one, its patterns before the watch's own.
func (w *Watch) resolve() error {
n := 0
for _, set := range []bool{w.File != "", w.Journal != nil, w.Source != ""} {
if set {
n++
}
}
if n > 1 {
return fmt.Errorf("give one source: file, journal or source")
}
if w.Source != "" && w.Source != SourceHousecarlDrops {
return fmt.Errorf("source %q: the only one besides file and journal is %s", w.Source, SourceHousecarlDrops)
}
if w.Preset != "" {
p, ok := Presets()[w.Preset]
if !ok {
return fmt.Errorf("there is no preset %q (guard presets lists them)", w.Preset)
}
if n == 0 {
switch {
case p.Source != "":
w.Source = p.Source
case p.Journal != nil:
j := *p.Journal
w.Journal = &j
default:
w.File = p.File
}
}
w.Match = append(append([]string{}, p.Match...), w.Match...)
w.Ignore = append(append([]string{}, p.Ignore...), w.Ignore...)
} else if n == 0 {
return fmt.Errorf("give a source: file, journal, source, or a preset")
}
if w.Journal != nil && len(w.Journal.Unit) == 0 && len(w.Journal.Identifier) == 0 {
return fmt.Errorf("journal: give a unit or an identifier (or both)")
}
if w.Source == SourceHousecarlDrops {
if len(w.Match) > 0 {
return fmt.Errorf("housecarl-drops has no patterns: every dropped packet counts")
}
switch strings.ToLower(w.Protocol) {
case "":
w.Protocol = "tcp"
case "tcp", "udp", "any":
default:
return fmt.Errorf("protocol %q: use tcp, udp or any", w.Protocol)
}
} else {
if w.Protocol != "" || w.Shadow {
return fmt.Errorf("protocol and shadow are for source housecarl-drops")
}
if len(w.Match) == 0 {
return fmt.Errorf("no match patterns: give some, or a preset")
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
"time"
"guard/internal/match"
)
// TestPresets runs each preset over its sample lines in testdata/presets:
// what it must find, and what it must leave alone.
func TestPresets(t *testing.T) {
for name, p := range Presets() {
if p.Source != "" {
continue
}
m, err := match.Compile(p.Match, p.Ignore)
if err != nil {
t.Fatalf("preset %s: %v", name, err)
}
f, err := os.Open(filepath.Join("..", "..", "testdata", "presets", name+".txt"))
if err != nil {
t.Errorf("preset %s has no sample lines: %v", name, err)
continue
}
sc := bufio.NewScanner(f)
n := 0
for sc.Scan() {
want, line, ok := strings.Cut(sc.Text(), "\t")
if !ok || strings.HasPrefix(want, "#") {
continue
}
n++
a, matched := m.Match(line)
switch {
case want == "-" && matched:
t.Errorf("%s: matched %s in %q", name, a, line)
case want != "-" && !matched:
t.Errorf("%s: no match in %q", name, line)
case want != "-" && a.String() != want:
t.Errorf("%s: %s, want %s, in %q", name, a, want, line)
}
}
f.Close()
if n == 0 {
t.Errorf("preset %s: no sample lines", name)
}
}
}
func TestExampleParses(t *testing.T) {
c, err := Parse([]byte(Example()))
if err != nil {
t.Fatal(err)
}
w := c.Watch[0]
if w.Kind() != "journal" || w.Ban.List != "web" || w.Hits != 5 || w.Within.D() != 10*time.Minute || len(w.Match) == 0 {
t.Errorf("the ssh watch: %+v", w)
}
if c.Defaults.Repeat.Max.D() != 7*24*time.Hour {
t.Errorf("repeat %+v", c.Defaults.Repeat)
}
}
func TestErrors(t *testing.T) {
base := "housecarl: {url: https://fw:8070, list: web}\n"
cases := []struct{ yaml, want string }{
{"", "give url"},
{base + "watch: [{name: a, preset: nope}]", `no preset "nope"`},
{base + "watch: [{name: a, file: /x, match: ['no address']}]", "exactly one"},
{base + "watch: [{name: a, file: /x, journal: {unit: x}, match: ['<IP>']}]", "one source"},
{base + "watch: [{name: a, file: /x}]", "no match patterns"},
{base + "watch: [{name: a, source: housecarl-drops, match: ['<IP>']}]", "no patterns"},
{base + "watch: [{name: a, preset: sshd, ban: {protocol: tcp}}]", "without ban.port"},
{base + "watch: [{name: a, preset: sshd, within: 10 minutes}]", "isn't a length of time"},
{base + "watch: [{name: a, preset: sshd}, {name: A, preset: sshd}]", "used twice"},
{base + "never_ban: ['not an address!']\nwatch: [{name: a, preset: sshd}]", "never_ban"},
{base + "bogus: 1\nwatch: [{name: a, preset: sshd}]", "bogus"},
{"housecarl: {url: https://fw:8070}\nwatch: [{name: a, preset: sshd}]", "which ban list"},
}
for _, c := range cases {
_, err := Parse([]byte(c.yaml))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%q: %v, want %q", c.yaml, err, c.want)
}
}
}
func TestPresetSourceAndOwnPatterns(t *testing.T) {
c, err := Parse([]byte(`housecarl: {socket: /run/housecarl/housecarl.sock, list: router}
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, match: ['custom <IP> line']}
- {name: scans, preset: housecarl-drops}
`))
if err != nil {
t.Fatal(err)
}
ssh := c.Watch[0]
if ssh.Kind() != "file" || ssh.File != "/var/log/auth.log" || ssh.Match[len(ssh.Match)-1] != "custom <IP> line" || len(ssh.Match) < 2 {
t.Errorf("own file and pattern beside the preset's: %+v", ssh)
}
if s := c.Watch[1]; s.Kind() != SourceHousecarlDrops || s.Protocol != "tcp" {
t.Errorf("housecarl-drops: %+v", s)
}
}
func TestDuration(t *testing.T) {
for in, want := range map[string]time.Duration{"30s": 30 * time.Second, "1d12h": 36 * time.Hour, "1w": 7 * 24 * time.Hour} {
if d, err := ParseDuration(in); err != nil || d != want {
t.Errorf("%s: %v %v", in, d, err)
}
if got := FormatDuration(want); got != in {
t.Errorf("format %v: %s, want %s", want, got, in)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
# Guard's configuration (docs/configuration.md). Guard reads it when it
# starts: systemctl restart guard after a change, and 'guard check' to
# check it first.
# Housecarl, which does the banning. Give Guard a token for its ban list
# alone; on the firewall:
# hc banlist add web --scope hosts --host 10.0.10.57
# hc user add guard-web --no-password --grant bans:write:web
# hc token create web --user guard-web # as root
housecarl:
url: https://router.lan:8070
# On the firewall itself, the local socket instead of url:
# socket: /run/housecarl/housecarl.sock
token_file: /etc/guard/token
# ca_cert: /etc/guard/housecarl-ca.pem # a private CA
# The ban list a watch bans into, unless it says another.
list: web
# Never banned, whatever the logs say: addresses, prefixes, host names
# (looked up every ten minutes). Housecarl itself never bans the inside
# networks, the firewall's own addresses, or this machine.
never_ban:
# - 198.51.100.7
# - 2001:db8:ffff::/48
# - vps.example.org
# What every watch does unless it says otherwise: this many matches of an
# address within this time ban it for this long. Each earlier ban of the
# address within "forget" doubles the time ("factor"), up to "max".
defaults:
hits: 5
within: 10m
ban: 1h
repeat: {factor: 2, max: 1w, forget: 1w}
watch:
# OpenSSH, from the journal (the preset knows where).
- name: ssh
preset: sshd
ban: {protocol: tcp, port: 22}
# A log file, with your own pattern: <IP> is where the address is.
# - name: web-login
# file: /var/log/nginx/access.log
# match:
# - '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
# ignore:
# - '/healthz'
# hits: 10
# ban: {time: 6h}
# Jarl's, Housecarl's or Steward's failed logins (presets jarl,
# housecarl, steward), banned from every port of the list's scope.
# - name: jarl
# preset: jarl
# On the firewall: sources whose packets Housecarl's logged rules keep
# dropping (TCP only: UDP sources can be faked). The token also needs
# log:read.
# - name: scans
# preset: housecarl-drops
# hits: 20
# within: 1m
# ban: {list: edge, time: 1d}
# Guard's memory of its bans (repeat offenders), and the socket 'guard
# status' asks.
state: /var/lib/guard/state.json
socket: /run/guard/guard.sock
log: {level: info, format: text}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
_ "embed"
"sync"
"go.yaml.in/yaml/v3"
)
//go:embed presets.yaml
var presetsYAML []byte
// Preset is a built-in watch: patterns, and where the lines usually are.
type Preset struct {
Description string `yaml:"description"`
File string `yaml:"file"`
Journal *Journal `yaml:"journal"`
Source string `yaml:"source"`
Match []string `yaml:"match"`
Ignore []string `yaml:"ignore"`
}
// Presets are the built-in presets by name.
var Presets = sync.OnceValue(func() map[string]Preset {
out := map[string]Preset{}
if err := yaml.Unmarshal(presetsYAML, &out); err != nil {
panic("presets.yaml: " + err.Error())
}
return out
})
//go:embed example.yaml
var exampleYAML []byte
// Example is a commented configuration (guard config --example).
func Example() string { return string(exampleYAML) }
+70
View File
@@ -0,0 +1,70 @@
# Guard's presets (docs/presets.md): patterns for common programs, and
# where their lines usually are. A watch's own file or journal replaces
# the preset's source; its match and ignore lines are added to these.
#
# Patterns are anchored where the program's format allows, and put the
# address where the program writes it, after anything a client chooses
# (a user name), so a chosen name can't plant another address. Lines
# from a syslog file carry a "host prog[pid]: " head that journal
# messages don't, hence "(?:^|: )" before a message's first word.
sshd:
description: OpenSSH's failed logins, unknown users and broken handshakes
# OpenSSH 9.8 and later (Debian 13) logs from sshd-session.
journal: {identifier: [sshd, sshd-session]}
match:
- '(?:^|: )Failed (?:password|publickey|keyboard-interactive/pam|none) for .* from <IP> port \d+(?: ssh2)?$'
- '(?:^|: )Invalid user .* from <IP> port \d+$'
- '(?:^|: )(?:Connection (?:closed|reset)|Disconnected) (?:by|from) (?:invalid|authenticating) user .* <IP> port \d+ \[preauth\]$'
- '(?:^|: )error: maximum authentication attempts exceeded for .* from <IP> port \d+ ssh2 \[preauth\]$'
- '(?:^|: )Unable to negotiate with <IP> port \d+: no matching (?:key exchange method|host key type|cipher|MAC) found'
- '(?:^|: )banner exchange: Connection from <IP> port \d+: invalid format$'
jarl:
description: failed logins and refused API tokens of Jarl (the BIND manager)
journal: {unit: jarl.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
housecarl:
description: failed logins and refused API tokens of Housecarl (the firewall manager)
journal: {unit: housecarl.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
steward:
description: failed logins and refused API tokens of Steward (the DHCP server)
journal: {unit: steward.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
bind-xfr:
description: zone transfers BIND refused (over TCP, so the address is real)
journal: {identifier: named}
match:
- 'client @0x[0-9a-f]+ <IP>#\d+ .*: zone transfer .* denied$'
postfix-sasl:
description: Postfix's failed SMTP logins (SASL)
journal: {identifier: [postfix/smtpd, postfix/submission/smtpd, postfix/smtps/smtpd]}
match:
- 'warning: [-._0-9A-Za-z]+\[<IP>\]: SASL [-A-Z0-9]+ authentication failed'
dovecot:
description: Dovecot's failed IMAP and POP3 logins
journal: {identifier: dovecot}
match:
- '(?:imap|pop3|submission|managesieve)-login: (?:Disconnected|Aborted login).*\(auth failed, \d+ attempts? in \d+ secs\): user=<.*>, .*rip=<IP>, lip='
nginx-auth:
description: nginx's failed HTTP basic authentication (error.log)
file: /var/log/nginx/error.log
match:
- '\buser "[^"]*"(?: was not found in "[^"]*"|: password mismatch), client: <IP>, server: '
housecarl-drops:
description: packets Housecarl's rules dropped and logged (on the firewall; TCP only by default)
source: housecarl-drops
+541
View File
@@ -0,0 +1,541 @@
// Package daemon is Guard at work: each watch's source feeds its lines
// to its patterns; an address that matches often enough is banned in
// Housecarl; bans Housecarl can't take now are sent again later.
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"guard/internal/bounty"
"guard/internal/config"
"guard/internal/housecarl"
"guard/internal/match"
"guard/internal/source"
"guard/internal/state"
)
// Banner is what bans go to: Housecarl (a fake in tests).
type Banner interface {
Ban(ctx context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error)
BanList(ctx context.Context, list string) (*housecarl.BanList, error)
}
// Daemon is a running Guard.
type Daemon struct {
cfg *config.Config
log *slog.Logger
hc Banner
state *state.State
never *NeverBan
watches []*Watch
started time.Time
mu sync.Mutex
groups map[string]int // ban list → IPv6 grouping
// pending are the bans Housecarl hasn't taken yet, by list and group.
pending map[string]state.Ban
wake chan struct{}
// refused remembers addresses Housecarl refused, so each is logged
// once an hour.
refused map[string]time.Time
// Now and RetryEvery can be changed by tests.
Now func() time.Time
RetryEvery time.Duration
}
// Watch is one watch at work.
type Watch struct {
Cfg *config.Watch
Matcher *match.Matcher
Source source.Source
Tracker *bounty.Tracker
lines, matches, bans atomic.Uint64
}
// New makes a Daemon. hc may be a fake; sources come from the watches'
// configuration unless a test sets them (Watch.Source).
func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State, log *slog.Logger) (*Daemon, error) {
d := &Daemon{cfg: cfg, log: log, hc: hc, state: st, never: NewNeverBan(cfg.NeverBan, log), groups: map[string]int{},
pending: map[string]state.Ban{}, wake: make(chan struct{}, 1), refused: map[string]time.Time{},
Now: time.Now, RetryEvery: 30 * time.Second}
for _, b := range st.Pending {
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
}
for _, w := range cfg.Watch {
if w.Disabled {
continue
}
m, err := match.Compile(w.Match, w.Ignore)
if err != nil {
return nil, fmt.Errorf("watch %s: %v", w.Name, err)
}
wt := &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())}
switch w.Kind() {
case "file":
wt.Source = &source.File{Pattern: w.File, Log: log.With("watch", w.Name)}
case "journal":
wt.Source = &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log.With("watch", w.Name)}
case config.SourceHousecarlDrops:
wt.Source = &source.HousecarlDrops{Client: logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log.With("watch", w.Name)}
}
d.watches = append(d.watches, wt)
}
if len(d.watches) == 0 {
return nil, errors.New("every watch is disabled: nothing to do")
}
return d, nil
}
// Watches are the watches at work (tests set their sources).
func (d *Daemon) Watches() []*Watch { return d.watches }
// Run watches until ctx ends.
func (d *Daemon) Run(ctx context.Context) error {
d.started = d.Now()
d.loadGroups(ctx)
go d.never.Run(ctx)
var wg sync.WaitGroup
for _, w := range d.watches {
lines := make(chan source.Line, 256)
wg.Add(2)
go func() {
defer wg.Done()
defer close(lines)
for {
err := w.Source.Run(ctx, lines)
if ctx.Err() != nil {
return
}
d.log.Error("the watch's source stopped; starting it again in 10 seconds", "watch", w.Cfg.Name, "err", err)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}()
go func() {
defer wg.Done()
for l := range lines {
d.Line(ctx, w, l)
}
}()
d.log.Info("watching", "watch", w.Cfg.Name, "source", Describe(w.Cfg), "patterns", len(w.Cfg.Match), "hits", w.Cfg.Hits, "within", w.Cfg.Within.String(), "list", w.Cfg.Ban.List)
}
wg.Add(1)
go func() {
defer wg.Done()
d.sender(ctx)
}()
if d.cfg.Socket != "" {
if err := d.serveStatus(ctx); err != nil {
d.log.Warn("no status socket (guard status won't work)", "socket", d.cfg.Socket, "err", err)
}
}
<-ctx.Done()
wg.Wait()
d.savePending()
return nil
}
// Describe says where a watch's lines come from.
func Describe(w *config.Watch) string {
switch w.Kind() {
case "journal":
var parts []string
if len(w.Journal.Unit) > 0 {
parts = append(parts, "unit "+strings.Join(w.Journal.Unit, ", "))
}
if len(w.Journal.Identifier) > 0 {
parts = append(parts, "identifier "+strings.Join(w.Journal.Identifier, ", "))
}
return "journal (" + strings.Join(parts, "; ") + ")"
case config.SourceHousecarlDrops:
return "Housecarl's dropped packets (" + w.Protocol + ")"
}
return "file " + w.File
}
// loadGroups asks Housecarl how each ban list groups IPv6 addresses, so
// bounties are counted as the list bans. A list that can't be read now
// counts by /64, its default.
func (d *Daemon) loadGroups(ctx context.Context) {
for _, w := range d.watches {
list := w.Cfg.Ban.List
d.mu.Lock()
_, done := d.groups[list]
d.mu.Unlock()
if done {
continue
}
c, cancel := context.WithTimeout(ctx, 10*time.Second)
bl, err := d.hc.BanList(c, list)
cancel()
group := 64
switch {
case err != nil:
d.log.Warn("can't read the ban list from Housecarl (yet); counting IPv6 addresses by /64", "list", list, "err", err)
case bl.Disabled:
d.log.Warn("the ban list is disabled in Housecarl: its bans won't be in force", "list", list)
group = bl.V6Group()
default:
group = bl.V6Group()
}
d.mu.Lock()
d.groups[list] = group
d.mu.Unlock()
}
}
func (d *Daemon) group(list string) int {
d.mu.Lock()
defer d.mu.Unlock()
if g, ok := d.groups[list]; ok {
return g
}
return 64
}
// Line handles one line of a watch.
func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) {
w.lines.Add(1)
addr := l.Addr
if !addr.IsValid() {
var ok bool
if addr, ok = w.Matcher.Match(l.Text); !ok {
return
}
}
w.matches.Add(1)
if why := d.never.Why(addr); why != "" {
d.log.Debug("match, but never banned", "watch", w.Cfg.Name, "address", addr, "why", why)
return
}
g := match.Group(addr, d.group(w.Cfg.Ban.List))
n, reached := w.Tracker.Add(g, l.Time)
d.log.Debug("match", "watch", w.Cfg.Name, "address", addr, "bounty", n, "hits", w.Cfg.Hits, "line", clip(l.Text, 300))
if !reached {
return
}
w.bans.Add(1)
d.ban(w, addr, g, l.Text)
}
// BanTime is how long a group's next ban lasts: the watch's time,
// times the repeat factor for each earlier ban within forget, up to the
// most.
func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
r := d.cfg.Defaults.Repeat
base := w.Ban.Time.D()
n := d.state.Repeats(group, d.Now().Add(-r.Forget.D()))
t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n)))
if t > r.Max.D() || t < 0 {
t = r.Max.D()
}
if t < base {
t = base
}
return t
}
func (d *Daemon) ban(w *Watch, addr netip.Addr, g netip.Prefix, line string) {
group := g.String()
t := d.BanTime(w.Cfg, group)
b := state.Ban{Time: d.Now(), Watch: w.Cfg.Name, List: w.Cfg.Ban.List, Address: addr.String(), Group: group,
Protocol: strings.ToLower(w.Cfg.Ban.Protocol), Ports: strings.Join(w.Cfg.Ban.Port, ","), Seconds: int(t / time.Second), Reason: clip(line, 300)}
d.log.Info("bounty reached; banning", "watch", w.Cfg.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
d.mu.Lock()
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
d.mu.Unlock()
select {
case d.wake <- struct{}{}:
default:
}
}
func portsText(b state.Ban) string {
if b.Ports == "" {
return "all"
}
if b.Protocol == "" {
return b.Ports
}
return b.Protocol + "/" + b.Ports
}
// sender sends the pending bans to Housecarl: at once, and again every
// RetryEvery while Housecarl can't be reached.
func (d *Daemon) sender(ctx context.Context) {
t := time.NewTicker(d.RetryEvery)
defer t.Stop()
failing := false
for {
select {
case <-ctx.Done():
return
case <-d.wake:
case <-t.C:
}
d.mu.Lock()
var todo []state.Ban
for _, b := range d.pending {
todo = append(todo, b)
}
d.mu.Unlock()
sort.Slice(todo, func(i, j int) bool { return todo[i].Time.Before(todo[j].Time) })
for _, b := range todo {
err := d.send(ctx, b)
if err != nil && !housecarl.Refused(err) {
if !failing {
d.log.Warn("can't reach Housecarl; the bans wait and are sent again", "err", err, "waiting", len(todo))
failing = true
}
break
}
if failing {
d.log.Info("Housecarl is back; sending the bans that waited")
failing = false
}
d.mu.Lock()
if cur, ok := d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports]; ok && cur.Time.Equal(b.Time) {
delete(d.pending, b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports)
}
d.mu.Unlock()
}
d.savePending()
}
}
// send makes one ban. A ban Housecarl refuses (never banned, no
// permission) isn't sent again.
func (d *Daemon) send(ctx context.Context, b state.Ban) error {
c, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// What waited is sent for the time it has left.
secs := b.Seconds - int(d.Now().Sub(b.Time)/time.Second)
if secs <= 0 {
return nil
}
res, err := d.hc.Ban(c, b.List, housecarl.BanInput{Address: b.Address, Protocol: b.Protocol, Ports: b.Ports, Time: secs,
Source: "guard/" + b.Watch, Reason: b.Reason})
if err != nil {
if housecarl.Refused(err) {
b.Result = err.Error()
d.state.Failed(b)
d.mu.Lock()
last, seen := d.refused[b.Group]
d.refused[b.Group] = d.Now()
d.mu.Unlock()
if !seen || d.Now().Sub(last) > time.Hour {
d.log.Warn("Housecarl refused the ban", "watch", b.Watch, "address", b.Address, "list", b.List, "err", err)
}
}
return err
}
b.Result = "banned"
for _, x := range res.Bans {
if x.Existing {
b.Result = "renewed"
}
}
d.state.Banned(b)
d.log.Info("banned", "watch", b.Watch, "address", b.Address, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(time.Duration(secs)*time.Second),
"result", b.Result, "connections_closed", res.Closed)
return nil
}
func (d *Daemon) savePending() {
d.mu.Lock()
var p []state.Ban
for _, b := range d.pending {
p = append(p, b)
}
d.mu.Unlock()
d.state.SetPending(p)
d.state.Forget(d.Now().Add(-d.cfg.Defaults.Repeat.Forget.D()))
if err := d.state.Save(); err != nil {
d.log.Warn("saving the state failed", "file", d.cfg.State, "err", err)
}
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---- status
// Status is what guard status shows.
type Status struct {
Started time.Time `json:"started"`
Watches []WatchStatus `json:"watches"`
Recent []state.Ban `json:"recent"`
Pending []state.Ban `json:"pending"`
}
// WatchStatus is a watch's.
type WatchStatus struct {
Name string `json:"name"`
Source string `json:"source"`
List string `json:"list"`
Hits int `json:"hits"`
Within string `json:"within"`
Lines uint64 `json:"lines"`
Matches uint64 `json:"matches"`
Bans uint64 `json:"bans"`
Top []bounty.Bounty `json:"top"`
}
// Status reports the watches, their highest bounties and the recent bans.
func (d *Daemon) Status() Status {
s := Status{Started: d.started}
for _, w := range d.watches {
s.Watches = append(s.Watches, WatchStatus{Name: w.Cfg.Name, Source: Describe(w.Cfg), List: w.Cfg.Ban.List, Hits: w.Cfg.Hits,
Within: w.Cfg.Within.String(), Lines: w.lines.Load(), Matches: w.matches.Load(), Bans: w.bans.Load(), Top: w.Tracker.Top(10, d.Now())})
}
s.Recent, s.Pending = d.state.Snapshot()
if len(s.Recent) > 50 {
s.Recent = s.Recent[:50]
}
return s
}
// serveStatus answers GET /status on the local socket (root and the
// guard user only: the socket is 0660 in /run/guard).
func (d *Daemon) serveStatus(ctx context.Context) error {
path := d.cfg.Socket
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
return err
}
os.Chmod(path, 0o660)
mux := http.NewServeMux()
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(d.Status())
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go srv.Serve(ln)
go func() {
<-ctx.Done()
srv.Close()
os.Remove(path)
}()
return nil
}
// ---- never banned
// NeverBan holds the addresses never banned: prefixes, and host names
// looked up every ten minutes.
type NeverBan struct {
log *slog.Logger
prefixes []netip.Prefix
names []string
mu sync.RWMutex
resolved map[string][]netip.Addr
// Lookup resolves names (tests).
Lookup func(ctx context.Context, host string) ([]netip.Addr, error)
}
// NewNeverBan reads never_ban (checked by config).
func NewNeverBan(list []string, log *slog.Logger) *NeverBan {
n := &NeverBan{log: log, resolved: map[string][]netip.Addr{},
Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
}}
for _, e := range list {
if p, err := netip.ParsePrefix(e); err == nil {
n.prefixes = append(n.prefixes, p.Masked())
} else if a, err := netip.ParseAddr(e); err == nil {
n.prefixes = append(n.prefixes, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()))
} else {
n.names = append(n.names, e)
}
}
return n
}
// Run looks the names up now and every ten minutes.
func (n *NeverBan) Run(ctx context.Context) {
if len(n.names) == 0 {
return
}
for {
n.resolve(ctx)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Minute):
}
}
}
func (n *NeverBan) resolve(ctx context.Context) {
for _, h := range n.names {
c, cancel := context.WithTimeout(ctx, 10*time.Second)
addrs, err := n.Lookup(c, h)
cancel()
if err != nil {
// Keep what it was: a name that can't be looked up now is still
// never banned as it was.
if n.log != nil {
n.log.Warn("can't look up a never_ban name; keeping its last addresses", "name", h, "err", err)
}
continue
}
for i := range addrs {
addrs[i] = addrs[i].Unmap()
}
n.mu.Lock()
n.resolved[h] = addrs
n.mu.Unlock()
}
}
// Why says why an address is never banned ("" when it may be).
func (n *NeverBan) Why(a netip.Addr) string {
a = a.Unmap()
switch {
case a.IsLoopback(), a.IsUnspecified(), a.IsMulticast(), a.IsLinkLocalUnicast():
return "loopback, link-local, multicast or unspecified"
}
for _, p := range n.prefixes {
if p.Contains(a) {
return "in never_ban " + p.String()
}
}
n.mu.RLock()
defer n.mu.RUnlock()
for h, addrs := range n.resolved {
for _, x := range addrs {
if x == a {
return "never_ban " + h
}
}
}
return ""
}
+179
View File
@@ -0,0 +1,179 @@
package daemon
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"guard/internal/config"
"guard/internal/housecarl"
"guard/internal/source"
"guard/internal/state"
)
type fakeHC struct {
mu sync.Mutex
bans []housecarl.BanInput
fail error
}
func (f *fakeHC) Ban(_ context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return nil, f.fail
}
f.bans = append(f.bans, in)
return &housecarl.BanResult{Bans: []housecarl.Ban{{Address: in.Address, List: list}}}, nil
}
func (f *fakeHC) BanList(_ context.Context, list string) (*housecarl.BanList, error) {
return &housecarl.BanList{Name: list, GroupV6: 64}, nil
}
func (f *fakeHC) setFail(err error) {
f.mu.Lock()
f.fail = err
f.mu.Unlock()
}
func (f *fakeHC) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.bans)
}
func (f *fakeHC) last() housecarl.BanInput {
f.mu.Lock()
defer f.mu.Unlock()
return f.bans[len(f.bans)-1]
}
type chanSource struct{ ch chan source.Line }
func (c *chanSource) Run(ctx context.Context, out chan<- source.Line) error {
for {
select {
case <-ctx.Done():
return nil
case l := <-c.ch:
out <- l
}
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("waited in vain: %s", what)
}
// TestDaemon: hits within the window ban, by the watch's ports and time;
// a repeat offender's ban is longer; IPv6 counts by /64; never_ban is
// never banned; bans wait while Housecarl can't be reached, and a
// refused one isn't sent again.
func TestDaemon(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Parse([]byte(`housecarl: {url: https://fw:8070, list: web}
never_ban: [198.51.100.0/24]
defaults: {hits: 3, within: 10m, ban: 1h, repeat: {factor: 2, max: 3h}}
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, ban: {protocol: tcp, port: 22}}
state: ` + filepath.Join(dir, "state.json") + `
socket: ` + filepath.Join(dir, "guard.sock") + `
`))
if err != nil {
t.Fatal(err)
}
st, _ := state.Load(cfg.State)
hc := &fakeHC{}
d, err := New(cfg, hc, nil, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
d.RetryEvery = 50 * time.Millisecond
src := &chanSource{ch: make(chan source.Line)}
d.Watches()[0].Source = src
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { d.Run(ctx); close(done) }()
fail := func(addr string, n int) {
for i := 0; i < n; i++ {
src.ch <- source.Line{Text: "Failed password for root from " + addr + " port 4000 ssh2", Time: time.Now()}
}
}
fail("203.0.113.9", 2)
time.Sleep(50 * time.Millisecond)
if hc.count() != 0 {
t.Fatal("banned below the hits")
}
fail("203.0.113.9", 1)
waitFor(t, "the first ban", func() bool { return hc.count() == 1 })
if b := hc.last(); b.Address != "203.0.113.9" || b.Protocol != "tcp" || b.Ports != "22" || b.Time < 3590 || b.Time > 3600 || b.Source != "guard/ssh" ||
!strings.Contains(b.Reason, "Failed password") {
t.Errorf("ban %+v", b)
}
fail("203.0.113.9", 3)
waitFor(t, "the repeat ban", func() bool { return hc.count() == 2 })
if b := hc.last(); b.Time < 7190 || b.Time > 7200 {
t.Errorf("a repeat offender's second ban: %ds, want 2h", b.Time)
}
fail("203.0.113.9", 3)
waitFor(t, "the third ban", func() bool { return hc.count() == 3 })
if b := hc.last(); b.Time < 10790 || b.Time > 10800 {
t.Errorf("capped at max: %ds, want 3h", b.Time)
}
// IPv6, by /64.
for _, a := range []string{"2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2::3"} {
fail(a, 1)
}
waitFor(t, "the IPv6 ban", func() bool { return hc.count() == 4 })
// never_ban.
fail("198.51.100.7", 5)
time.Sleep(50 * time.Millisecond)
if hc.count() != 4 {
t.Errorf("never_ban banned: %+v", hc.last())
}
// Housecarl down: the ban waits, then goes.
hc.setFail(errors.New("connection refused"))
fail("203.0.113.20", 3)
time.Sleep(150 * time.Millisecond)
if _, pending := st.Snapshot(); hc.count() != 4 || len(pending) != 1 {
t.Errorf("while Housecarl is down: %d bans, %d pending", hc.count(), len(pending))
}
hc.setFail(nil)
waitFor(t, "the ban that waited", func() bool { return hc.count() == 5 && hc.last().Address == "203.0.113.20" })
// Refused: recorded, not sent again.
hc.setFail(&housecarl.Error{Status: 422, Code: "never_ban", Message: "203.0.113.30 isn't banned: it is inside"})
fail("203.0.113.30", 3)
waitFor(t, "the refusal recorded", func() bool {
recent, _ := st.Snapshot()
return len(recent) > 0 && strings.Contains(recent[0].Result, "inside")
})
time.Sleep(150 * time.Millisecond)
if _, pending := st.Snapshot(); len(pending) != 0 {
t.Errorf("a refused ban waits: %+v", pending)
}
s := d.Status()
if len(s.Watches) != 1 || s.Watches[0].Bans != 6 || s.Watches[0].Matches != 23 {
t.Errorf("status %+v", s.Watches)
}
cancel()
<-done
// The state outlives the daemon.
st2, err := state.Load(cfg.State)
if err != nil || st2.Repeats("203.0.113.9/32", time.Now().Add(-time.Hour)) != 3 {
t.Errorf("saved state: %v %+v", err, st2.Offences)
}
}
+253
View File
@@ -0,0 +1,253 @@
// Package housecarl is Guard's client for Housecarl's API: ban lists and
// their bans, and the log of dropped packets (Housecarl's docs/bans.md
// and docs/api.md).
package housecarl
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"guard/internal/version"
)
// Prefix starts every API path.
const Prefix = "/api/v1"
// Client talks to one Housecarl.
type Client struct {
base string
token string
http *http.Client
}
// Options say how to reach Housecarl.
type Options struct {
URL, Socket string
Token string
CACert string
Insecure bool
Timeout time.Duration
}
// New makes a client.
func New(o Options) (*Client, error) {
timeout := o.Timeout
if timeout == 0 {
timeout = 15 * time.Second
}
tr := http.DefaultTransport.(*http.Transport).Clone()
base := strings.TrimRight(o.URL, "/")
if o.Socket != "" {
sock := o.Socket
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
}
base = "http://housecarl"
}
if o.CACert != "" || o.Insecure {
cfg := &tls.Config{InsecureSkipVerify: o.Insecure}
if o.CACert != "" {
pem, err := os.ReadFile(o.CACert)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("%s holds no PEM certificate", o.CACert)
}
cfg.RootCAs = pool
}
tr.TLSClientConfig = cfg
}
return &Client{base: base, token: strings.TrimSpace(o.Token), http: &http.Client{Transport: tr, Timeout: timeout}}, nil
}
// Error is an error answer from Housecarl.
type Error struct {
Status int
Code string
Message string
}
func (e *Error) Error() string {
if e.Message == "" {
return fmt.Sprintf("Housecarl answered %d", e.Status)
}
return e.Message
}
// Refused reports whether Housecarl refused a request for good: asking
// again won't help (a bad request, no permission, an address never
// banned). Anything else (no answer, a server error) is worth retrying.
func Refused(err error) bool {
var he *Error
return errors.As(err, &he) && he.Status >= 400 && he.Status < 500 && he.Status != http.StatusTooManyRequests
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
u := c.base + Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.Header.Set("User-Agent", "guard/"+version.Short())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode >= 300 {
var eb struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.Unmarshal(data, &eb)
return &Error{Status: resp.StatusCode, Code: eb.Error.Code, Message: eb.Error.Message}
}
if out != nil && len(data) > 0 {
return json.Unmarshal(data, out)
}
return nil
}
// BanList is a Housecarl ban list (the fields Guard uses).
type BanList struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
DefaultTime int `json:"default_time"`
MaxTime int `json:"max_time"`
GroupV6 int `json:"group_v6"`
Disabled bool `json:"disabled"`
}
// V6Group is the IPv6 prefix length the list bans by.
func (b *BanList) V6Group() int {
if b.GroupV6 > 0 {
return b.GroupV6
}
return 64
}
// Ban is an active ban.
type Ban struct {
ID int64 `json:"id"`
List string `json:"list"`
Address string `json:"address"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Expires *time.Time `json:"expires"`
Timeout int `json:"timeout"`
Created time.Time `json:"created"`
Who string `json:"who"`
Source string `json:"source"`
Reason string `json:"reason"`
Existing bool `json:"existing"`
}
// BanInput asks for a ban.
type BanInput struct {
Address string `json:"address"`
Protocol string `json:"protocol,omitempty"`
Ports string `json:"ports,omitempty"`
Time int `json:"time,omitempty"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
// BanResult is what a ban made.
type BanResult struct {
Bans []Ban `json:"bans"`
Closed int `json:"closed"`
}
// LogEntry is a packet in Housecarl's log.
type LogEntry struct {
Time time.Time `json:"time"`
Prefix string `json:"prefix"`
Protocol string `json:"protocol"`
Source string `json:"source"`
Dest string `json:"destination"`
SrcPort int `json:"source_port"`
DestPort int `json:"destination_port"`
Verdict string `json:"verdict"`
Shadow bool `json:"shadow"`
}
func listPath(list string) string { return "/banlists/" + url.PathEscape(list) }
// BanList reads a ban list.
func (c *Client) BanList(ctx context.Context, list string) (*BanList, error) {
var b BanList
if err := c.do(ctx, "GET", listPath(list), nil, nil, &b); err != nil {
return nil, err
}
return &b, nil
}
// Ban bans an address.
func (c *Client) Ban(ctx context.Context, list string, in BanInput) (*BanResult, error) {
var out BanResult
if err := c.do(ctx, "POST", listPath(list)+"/bans", nil, in, &out); err != nil {
return nil, err
}
return &out, nil
}
// Unban lifts every ban of an address in a list.
func (c *Client) Unban(ctx context.Context, list, addr string) (int, error) {
var out map[string]int
err := c.do(ctx, "DELETE", listPath(list)+"/bans", url.Values{"address": {addr}}, nil, &out)
return out["removed"], err
}
// Bans lists a list's active bans ("" for every list the token may read).
func (c *Client) Bans(ctx context.Context, list string) ([]Ban, error) {
path := "/bans"
if list != "" {
path = listPath(list) + "/bans"
}
var out []Ban
err := c.do(ctx, "GET", path, nil, nil, &out)
return out, err
}
// Log reads Housecarl's log of packets after a time, newest first.
func (c *Client) Log(ctx context.Context, after time.Time) ([]LogEntry, error) {
var out []LogEntry
err := c.do(ctx, "GET", "/log", url.Values{"after": {after.UTC().Format(time.RFC3339Nano)}, "limit": {"2000"}}, nil, &out)
return out, err
}
+89
View File
@@ -0,0 +1,89 @@
// Package match finds the address in a log line: patterns are regular
// expressions (Go's, RE2) with one <IP>, which matches an IPv4 or IPv6
// address (bracketed, IPv4-mapped, with a zone). The address is checked
// with netip, so what only looks like one (a time, 12:34:56) never is.
package match
import (
"fmt"
"net/netip"
"regexp"
"strings"
)
// Placeholder is where a pattern's address is.
const Placeholder = "<IP>"
// ipRe matches an address: IPv4, or IPv6 (which may end in IPv4,
// ::ffff:203.0.113.9), bracketed or not, with an optional zone.
const ipRe = `\[?(?P<ip>[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){1,7}(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3})?(?:%[0-9A-Za-z_.-]+)?)\]?`
// Matcher holds a watch's patterns.
type Matcher struct {
match []*regexp.Regexp
ignore []*regexp.Regexp
}
// Compile makes a Matcher. Each match pattern must hold <IP> exactly
// once; ignore patterns are plain regular expressions.
func Compile(match, ignore []string) (*Matcher, error) {
m := &Matcher{}
for _, p := range match {
if n := strings.Count(p, Placeholder); n != 1 {
return nil, fmt.Errorf("pattern %q has %d %s; it needs exactly one, where the address is", p, n, Placeholder)
}
re, err := regexp.Compile(strings.Replace(p, Placeholder, ipRe, 1))
if err != nil {
return nil, fmt.Errorf("pattern %q: %v", p, err)
}
m.match = append(m.match, re)
}
for _, p := range ignore {
re, err := regexp.Compile(p)
if err != nil {
return nil, fmt.Errorf("ignore pattern %q: %v", p, err)
}
m.ignore = append(m.ignore, re)
}
return m, nil
}
// Match returns the address of the first pattern a line matches, unless
// an ignore pattern matches it too.
func (m *Matcher) Match(line string) (netip.Addr, bool) {
for _, re := range m.match {
sub := re.FindStringSubmatch(line)
if sub == nil {
continue
}
text := sub[re.SubexpIndex("ip")]
if i := strings.IndexByte(text, '%'); i >= 0 {
text = text[:i]
}
a, err := netip.ParseAddr(text)
if err != nil {
continue
}
for _, ig := range m.ignore {
if ig.MatchString(line) {
return netip.Addr{}, false
}
}
return a.Unmap(), true
}
return netip.Addr{}, false
}
// Group is the prefix an address is counted and banned by: an IPv4
// address alone, an IPv6 address by its /v6 (64: what one customer
// usually has).
func Group(a netip.Addr, v6 int) netip.Prefix {
if a.Is4() || v6 <= 0 || v6 > 128 {
if a.Is4() {
return netip.PrefixFrom(a, 32)
}
v6 = 128
}
p, _ := a.Prefix(v6)
return p
}
+50
View File
@@ -0,0 +1,50 @@
package match
import (
"net/netip"
"testing"
)
func TestMatch(t *testing.T) {
m, err := Compile([]string{`from <IP> port`, `client: <IP>,`, `^conn <IP>$`}, []string{`trusted-scanner`})
if err != nil {
t.Fatal(err)
}
cases := map[string]string{
"from 203.0.113.9 port 22": "203.0.113.9",
"from 2001:db8::9 port 22": "2001:db8::9",
"from ::ffff:203.0.113.9 port 22": "203.0.113.9",
"from [2001:db8::9] port 22": "2001:db8::9",
"from fe80::1%eth0 port 22": "fe80::1",
"client: 203.0.113.9, server": "203.0.113.9",
"conn 2001:db8:1:2:3:4:5:6": "2001:db8:1:2:3:4:5:6",
"from 12:34:56 port 22": "", // a time, not an address
"from 999.1.1.1 port 22": "",
"from 203.0.113.9 port 22 trusted-scanner": "", // ignored
"nothing here": "",
}
for line, want := range cases {
a, ok := m.Match(line)
if got := map[bool]string{true: a.String(), false: ""}[ok]; got != want {
t.Errorf("%q: %q, want %q", line, got, want)
}
}
if _, err := Compile([]string{"<IP> and <IP>"}, nil); err == nil {
t.Error("two <IP> accepted")
}
if _, err := Compile([]string{"<IP> (unclosed"}, nil); err == nil {
t.Error("a broken pattern accepted")
}
}
func TestGroup(t *testing.T) {
if g := Group(netip.MustParseAddr("2001:db8:1:2:aa::1"), 64); g.String() != "2001:db8:1:2::/64" {
t.Errorf("/64: %s", g)
}
if g := Group(netip.MustParseAddr("203.0.113.9"), 64); g.String() != "203.0.113.9/32" {
t.Errorf("IPv4: %s", g)
}
if g := Group(netip.MustParseAddr("2001:db8::1"), 128); g.String() != "2001:db8::1/128" {
t.Errorf("/128: %s", g)
}
}
+423
View File
@@ -0,0 +1,423 @@
// Package source reads the lines Guard watches: log files followed like
// tail -F, the systemd journal (through journalctl), and Housecarl's log
// of dropped packets.
package source
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"guard/internal/housecarl"
)
// Line is one line of a log, with its time: when it was written (the
// journal, Housecarl's log) or read (a file).
type Line struct {
Text string
Time time.Time
// Addr is the address, for sources that know it without a pattern
// (Housecarl's log).
Addr netip.Addr
}
// Source sends its lines until ctx ends.
type Source interface {
Run(ctx context.Context, out chan<- Line) error
}
// maxLine is the longest line kept; longer ones are cut.
const maxLine = 64 << 10
// ---- files
// File follows a file, or every file a glob matches, from its end: lines
// written from now on. A file that appears later is read from its start;
// one that is rotated (replaced) is finished, then the new one is read;
// one that is truncated is read again from its start.
type File struct {
Pattern string
// Poll is how often the files are checked (default 1s).
Poll time.Duration
Log *slog.Logger
// Now gives the lines' time (tests).
Now func() time.Time
}
type tailer struct {
path string
f *os.File
info os.FileInfo
partial []byte
}
// Run follows the files.
func (s *File) Run(ctx context.Context, out chan<- Line) error {
poll := s.Poll
if poll == 0 {
poll = time.Second
}
now := s.Now
if now == nil {
now = time.Now
}
tails := map[string]*tailer{}
defer func() {
for _, t := range tails {
if t.f != nil {
t.f.Close()
}
}
}()
first, warned := true, false
for {
paths, _ := filepath.Glob(s.Pattern)
if len(paths) == 0 && !warned && s.Log != nil {
s.Log.Warn("no file to watch yet; checking every few seconds", "file", s.Pattern)
warned = true
}
for _, p := range paths {
if _, ok := tails[p]; !ok {
t := &tailer{path: p}
// Files there when Guard starts are read from their end,
// files that appear later from their start.
if err := t.open(first); err != nil {
if s.Log != nil {
s.Log.Warn("can't read the file", "file", p, "err", err)
}
continue
}
tails[p] = t
warned = false
}
}
first = false
for p, t := range tails {
if err := t.step(ctx, out, now); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
if s.Log != nil {
s.Log.Warn("reading the file failed", "file", p, "err", err)
}
t.f.Close()
delete(tails, p)
}
}
select {
case <-ctx.Done():
return nil
case <-time.After(poll):
}
}
}
func (t *tailer) open(atEnd bool) error {
f, err := os.Open(t.path)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
if atEnd {
if _, err := f.Seek(0, io.SeekEnd); err != nil {
f.Close()
return err
}
}
t.f, t.info, t.partial = f, info, nil
return nil
}
// step sends what was written since the last step, and follows a
// rotation or truncation.
func (t *tailer) step(ctx context.Context, out chan<- Line, now func() time.Time) error {
if err := t.drain(ctx, out, now); err != nil {
return err
}
info, err := os.Stat(t.path)
if err != nil {
// Rotated away, and the new file isn't there yet: keep the old.
return nil
}
if !os.SameFile(info, t.info) {
t.f.Close()
return t.open(false)
}
if pos, err := t.f.Seek(0, io.SeekCurrent); err == nil && info.Size() < pos {
// Truncated (copytruncate): from the start again.
if _, err := t.f.Seek(0, io.SeekStart); err != nil {
return err
}
t.partial = nil
t.info = info
}
return nil
}
func (t *tailer) drain(ctx context.Context, out chan<- Line, now func() time.Time) error {
buf := make([]byte, 32<<10)
for {
n, err := t.f.Read(buf)
if n > 0 {
data := append(t.partial, buf[:n]...)
for {
i := bytes.IndexByte(data, '\n')
if i < 0 {
break
}
line := data[:i]
data = data[i+1:]
if len(line) > maxLine {
line = line[:maxLine]
}
select {
case out <- Line{Text: strings.TrimRight(string(line), "\r"), Time: now()}:
case <-ctx.Done():
return ctx.Err()
}
}
if len(data) > maxLine {
data = data[:maxLine]
}
t.partial = append([]byte(nil), data...)
}
if err == io.EOF || n == 0 {
return nil
}
if err != nil {
return err
}
}
}
// ---- the journal
// Journal follows the systemd journal's entries of some units and/or
// syslog identifiers, through journalctl (no libsystemd: Guard has no
// cgo). It starts with what is written from now on; when journalctl ends
// it is started again after the last entry seen.
type Journal struct {
Units, Identifiers []string
// Command is journalctl (GUARD_JOURNALCTL overrides it, for tests).
Command string
Log *slog.Logger
}
// JournalCommand is the journalctl to run.
func JournalCommand() string {
if c := os.Getenv("GUARD_JOURNALCTL"); c != "" {
return c
}
return "journalctl"
}
// JournalArgs are journalctl's arguments: following from now, or after a
// cursor; or (follow false) the last n entries.
func JournalArgs(units, idents []string, follow bool, cursor string, n int) []string {
args := []string{"--output=json", "--no-pager"}
switch {
case !follow:
args = append(args, "--lines="+strconv.Itoa(n))
case cursor != "":
args = append(args, "--follow", "--lines=all", "--after-cursor="+cursor)
default:
args = append(args, "--follow", "--lines=0")
}
for _, u := range units {
args = append(args, "--unit="+u)
}
for _, i := range idents {
args = append(args, "--identifier="+i)
}
return args
}
// Run follows the journal.
func (s *Journal) Run(ctx context.Context, out chan<- Line) error {
cmd := s.Command
if cmd == "" {
cmd = JournalCommand()
}
cursor := ""
for {
c := exec.CommandContext(ctx, cmd, JournalArgs(s.Units, s.Identifiers, true, cursor, 0)...)
var stderr bytes.Buffer
c.Stderr = &stderr
stdout, err := c.StdoutPipe()
if err != nil {
return err
}
if err := c.Start(); err != nil {
return fmt.Errorf("starting %s: %w", cmd, err)
}
cursor = ReadJournal(ctx, stdout, out, cursor)
err = c.Wait()
if ctx.Err() != nil {
return nil
}
if s.Log != nil {
s.Log.Warn("journalctl ended; starting it again in 5 seconds", "err", err, "stderr", strings.TrimSpace(stderr.String()))
}
select {
case <-ctx.Done():
return nil
case <-time.After(5 * time.Second):
}
}
}
// ReadJournal sends the messages of journalctl's JSON output, and returns
// the last entry's cursor (or cursor, if there was none).
func ReadJournal(ctx context.Context, r io.Reader, out chan<- Line, cursor string) string {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 4<<20)
for sc.Scan() {
l, c, ok := ParseJournal(sc.Bytes())
if c != "" {
cursor = c
}
if !ok {
continue
}
select {
case out <- l:
case <-ctx.Done():
return cursor
}
}
return cursor
}
// ParseJournal reads one entry of journalctl --output=json: its message
// (a string, or bytes when it isn't UTF-8), time and cursor.
func ParseJournal(data []byte) (Line, string, bool) {
var e struct {
Message json.RawMessage `json:"MESSAGE"`
Cursor string `json:"__CURSOR"`
Realtime string `json:"__REALTIME_TIMESTAMP"`
}
if json.Unmarshal(data, &e) != nil {
return Line{}, "", false
}
var text string
if json.Unmarshal(e.Message, &text) != nil {
var b []byte
var ints []int
if json.Unmarshal(e.Message, &ints) != nil {
return Line{}, e.Cursor, false
}
for _, n := range ints {
b = append(b, byte(n))
}
text = string(b)
}
if len(text) > maxLine {
text = text[:maxLine]
}
t := time.Now()
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
t = time.UnixMicro(us)
}
return Line{Text: text, Time: t}, e.Cursor, true
}
// ---- Housecarl's log
// LogReader reads Housecarl's log of packets.
type LogReader interface {
Log(ctx context.Context, after time.Time) ([]housecarl.LogEntry, error)
}
// HousecarlDrops follows the packets Housecarl's rules dropped (or
// rejected) and logged, from now on. Protocol limits them ("tcp",
// "udp", "any"); Shadow counts shadow mode's, which weren't dropped.
type HousecarlDrops struct {
Client LogReader
Protocol string
Shadow bool
Poll time.Duration
Log *slog.Logger
}
// Run follows the log.
func (s *HousecarlDrops) Run(ctx context.Context, out chan<- Line) error {
poll := s.Poll
if poll == 0 {
poll = 2 * time.Second
}
after := time.Now()
failing := false
for {
entries, err := s.Client.Log(ctx, after)
switch {
case err != nil && !failing:
if s.Log != nil {
s.Log.Warn("reading Housecarl's log failed (the token needs log:read); trying again", "err", err)
}
failing = true
case err == nil:
failing = false
// Newest first: send them oldest first.
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.Time.After(after) {
after = e.Time
}
if l, ok := s.line(e); ok {
select {
case out <- l:
case <-ctx.Done():
return nil
}
}
}
}
select {
case <-ctx.Done():
return nil
case <-time.After(poll):
}
}
}
func (s *HousecarlDrops) line(e housecarl.LogEntry) (Line, bool) {
if e.Verdict != "drop" && e.Verdict != "reject" || e.Shadow && !s.Shadow {
return Line{}, false
}
if p := strings.ToLower(s.Protocol); p != "any" && p != "" && e.Protocol != p {
return Line{}, false
}
a, err := netip.ParseAddr(e.Source)
if err != nil {
return Line{}, false
}
text := fmt.Sprintf("%s %s %s -> %s", strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(e.Prefix), ":")), e.Protocol,
hostPort(e.Source, e.SrcPort), hostPort(e.Dest, e.DestPort))
return Line{Text: text, Time: e.Time, Addr: a.Unmap()}, true
}
func hostPort(a string, p int) string {
if p == 0 {
return a
}
if strings.Contains(a, ":") {
return "[" + a + "]:" + strconv.Itoa(p)
}
return a + ":" + strconv.Itoa(p)
}
+158
View File
@@ -0,0 +1,158 @@
package source
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"guard/internal/housecarl"
)
// collect gathers lines until want of them came, or a timeout.
func collect(t *testing.T, ch <-chan Line, want int) []string {
t.Helper()
var got []string
deadline := time.After(5 * time.Second)
for len(got) < want {
select {
case l := <-ch:
got = append(got, l.Text)
case <-deadline:
t.Fatalf("got %d lines, want %d: %q", len(got), want, got)
}
}
return got
}
func appendTo(t *testing.T, path, text string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
t.Fatal(err)
}
f.WriteString(text)
f.Close()
}
// TestFile: from the end of a file there at the start; a partial line
// waits for its end; rotation finishes the old file and reads the new
// from its start; truncation starts again; a file that appears later is
// read from its start.
func TestFile(t *testing.T) {
dir := t.TempDir()
log := filepath.Join(dir, "auth.log")
appendTo(t, log, "old line, not read\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&File{Pattern: filepath.Join(dir, "*.log"), Poll: 20 * time.Millisecond}).Run(ctx, ch)
time.Sleep(100 * time.Millisecond)
appendTo(t, log, "one\ntw")
time.Sleep(60 * time.Millisecond)
appendTo(t, log, "o\r\n")
if got := collect(t, ch, 2); strings.Join(got, ",") != "one,two" {
t.Errorf("appended: %q", got)
}
// Rotation: a last line in the old file, then a new file.
appendTo(t, log, "last of old\n")
os.Rename(log, log+".1")
appendTo(t, log, "first of new\n")
if got := collect(t, ch, 2); strings.Join(got, ",") != "last of old,first of new" {
t.Errorf("rotated: %q", got)
}
// Truncation (copytruncate): seen while the file is shorter than what
// was read.
time.Sleep(60 * time.Millisecond)
os.WriteFile(log, []byte("trunc\n"), 0o644)
if got := collect(t, ch, 1); got[0] != "trunc" {
t.Errorf("truncated: %q", got)
}
// A file that appears later.
appendTo(t, filepath.Join(dir, "other.log"), "new file\n")
if got := collect(t, ch, 1); got[0] != "new file" {
t.Errorf("new file: %q", got)
}
}
// TestJournal: journalctl's JSON through a fake journalctl, started
// again after the last cursor when it ends.
func TestJournal(t *testing.T) {
dir := t.TempDir()
args := filepath.Join(dir, "args")
fake := filepath.Join(dir, "journalctl")
os.WriteFile(fake, []byte(`#!/bin/sh
echo "$*" >> `+args+`
echo '{"MESSAGE":"Failed password for root from 203.0.113.9 port 22 ssh2","__CURSOR":"c1","__REALTIME_TIMESTAMP":"1761127200000000"}'
echo '{"MESSAGE":[104,105,255],"__CURSOR":"c2","__REALTIME_TIMESTAMP":"1761127201000000"}'
echo 'not json'
`), 0o755)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&Journal{Units: []string{"ssh.service"}, Identifiers: []string{"sshd", "sshd-session"}, Command: fake}).Run(ctx, ch)
got := collect(t, ch, 2)
if got[0] != "Failed password for root from 203.0.113.9 port 22 ssh2" || got[1] != "hi\xff" {
t.Errorf("lines %q", got)
}
// It ends; after 5 seconds it starts again after c2.
time.Sleep(5500 * time.Millisecond)
b, _ := os.ReadFile(args)
calls := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(calls) < 2 || !strings.Contains(calls[0], "--follow --lines=0 --unit=ssh.service --identifier=sshd --identifier=sshd-session") ||
!strings.Contains(calls[1], "--after-cursor=c2") {
t.Errorf("journalctl's arguments: %q", calls)
}
l, _, _ := ParseJournal([]byte(`{"MESSAGE":"x","__REALTIME_TIMESTAMP":"1761127200000000"}`))
if l.Time.Unix() != 1761127200 {
t.Errorf("time %v", l.Time)
}
}
type fakeLog struct{ entries []housecarl.LogEntry }
func (f *fakeLog) Log(_ context.Context, after time.Time) ([]housecarl.LogEntry, error) {
var out []housecarl.LogEntry
for _, e := range f.entries {
if e.Time.After(after) {
out = append([]housecarl.LogEntry{e}, out...) // newest first
}
}
return out, nil
}
// TestHousecarlDrops: drops and rejects of the protocol, not accepts or
// shadow mode's, each with its source address.
func TestHousecarlDrops(t *testing.T) {
now := time.Now().Add(time.Second)
f := &fakeLog{entries: []housecarl.LogEntry{
{Time: now, Prefix: "hc input #3 drop: ", Protocol: "tcp", Source: "203.0.113.9", SrcPort: 4000, Dest: "198.51.100.1", DestPort: 22, Verdict: "drop"},
{Time: now.Add(time.Millisecond), Protocol: "udp", Source: "203.0.113.10", Verdict: "drop"},
{Time: now.Add(2 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.11", Verdict: "accept"},
{Time: now.Add(3 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.12", Verdict: "drop", Shadow: true},
{Time: now.Add(4 * time.Millisecond), Protocol: "tcp", Source: "2001:db8::9", Verdict: "reject"},
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&HousecarlDrops{Client: f, Protocol: "tcp", Poll: 20 * time.Millisecond}).Run(ctx, ch)
var got []string
for len(got) < 2 {
select {
case l := <-ch:
got = append(got, l.Addr.String()+" "+l.Text)
case <-time.After(3 * time.Second):
t.Fatalf("lines %q", got)
}
}
if got[0] != "203.0.113.9 hc input #3 drop tcp 203.0.113.9:4000 -> 198.51.100.1:22" || !strings.HasPrefix(got[1], "2001:db8::9 ") {
t.Errorf("lines %q", got)
}
select {
case l := <-ch:
t.Errorf("more: %+v", l)
case <-time.After(100 * time.Millisecond):
}
}
+151
View File
@@ -0,0 +1,151 @@
// Package state keeps what Guard must remember across a restart: the
// bans it made (for repeat offenders' longer bans, and guard status),
// and the bans Housecarl hasn't taken yet.
package state
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"time"
)
// MaxRecent is how many recent bans are kept for guard status.
const MaxRecent = 200
// Ban is a ban Guard made (or is waiting to make).
type Ban struct {
Time time.Time `json:"time"`
Watch string `json:"watch"`
List string `json:"list"`
Address string `json:"address"`
// Group is what is counted for repeats: the address, or its IPv6
// prefix.
Group string `json:"group"`
Protocol string `json:"protocol,omitempty"`
Ports string `json:"ports,omitempty"`
Seconds int `json:"seconds"`
Reason string `json:"reason,omitempty"`
// Result is what Housecarl said: "banned", "renewed", or why not.
Result string `json:"result,omitempty"`
}
// State is Guard's memory.
type State struct {
mu sync.Mutex
path string
// Offences are the times each group was banned.
Offences map[string][]time.Time `json:"offences"`
Recent []Ban `json:"recent"`
Pending []Ban `json:"pending"`
}
// Load reads the state (an empty one if the file isn't there).
func Load(path string) (*State, error) {
s := &State{path: path, Offences: map[string][]time.Time{}}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, s); err != nil {
return nil, err
}
if s.Offences == nil {
s.Offences = map[string][]time.Time{}
}
return s, nil
}
// Save writes the state atomically.
func (s *State) Save() error {
s.mu.Lock()
data, err := json.Marshal(s)
s.mu.Unlock()
if err != nil || s.path == "" {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o640); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Repeats counts a group's bans since a time.
func (s *State) Repeats(group string, since time.Time) int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, t := range s.Offences[group] {
if t.After(since) {
n++
}
}
return n
}
// Banned records a ban Housecarl made.
func (s *State) Banned(b Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.Offences[b.Group] = append(s.Offences[b.Group], b.Time)
s.recent(b)
}
// Failed records a ban Housecarl refused.
func (s *State) Failed(b Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.recent(b)
}
func (s *State) recent(b Ban) {
s.Recent = append(s.Recent, b)
if len(s.Recent) > MaxRecent {
s.Recent = append([]Ban(nil), s.Recent[len(s.Recent)-MaxRecent:]...)
}
}
// SetPending replaces the bans waiting for Housecarl.
func (s *State) SetPending(p []Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.Pending = append([]Ban(nil), p...)
}
// Snapshot returns the recent bans (newest first) and the pending ones.
func (s *State) Snapshot() (recent, pending []Ban) {
s.mu.Lock()
defer s.mu.Unlock()
for i := len(s.Recent) - 1; i >= 0; i-- {
recent = append(recent, s.Recent[i])
}
return recent, append([]Ban(nil), s.Pending...)
}
// Forget drops offences older than a time.
func (s *State) Forget(before time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
for g, ts := range s.Offences {
var keep []time.Time
for _, t := range ts {
if t.After(before) {
keep = append(keep, t)
}
}
if len(keep) == 0 {
delete(s.Offences, g)
} else {
s.Offences[g] = keep
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package version holds Guard's version, set at build time with
//
// go build -ldflags "-X guard/internal/version.Version=1.2.3 -X guard/internal/version.Tag=v1.2.3"
//
// The Makefile sets Version from the VERSION file, and Tag only when the
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
// is changed. Only such a build reports a version number. Other builds
// are development builds, identified by their commit (which Go records
// from git by itself) and by where they stand relative to the last tag
// (Describe, from git describe).
package version
import (
"regexp"
"runtime/debug"
"strconv"
"strings"
"sync"
)
// Version is the Guard version (the VERSION file).
var Version = "0.1.0-dev"
// Tag is the release tag the build is of ("" when it isn't a release).
var Tag = ""
// Commit is the git commit the build is of. It is read from the build
// information when not set at build time.
var Commit = ""
// Describe is 'git describe --tags --long' of the build's commit
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
// commits a development build is past the last tag.
var Describe = ""
// Repo is Guard's source repository.
const Repo = "https://gitea.adbraun.lol/adam/guard"
// Build describes what a binary was built from.
type Build struct {
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
// Since is the last tag before a development build's commit, and
// Ahead how many commits the build is past it (0: the tag's commit,
// with uncommitted changes).
Since string `json:"since,omitempty"`
Ahead int `json:"ahead,omitempty"`
// Label is the short form to show: the tag of a release, otherwise
// the abbreviated commit (or the version when the commit is unknown).
Label string `json:"label"`
// URL is the release or commit in the repository.
URL string `json:"url"`
}
var info = sync.OnceValue(func() Build {
commit, modified := Commit, false
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
if commit == "" {
commit = s.Value
}
case "vcs.modified":
modified = s.Value == "true"
}
}
}
b := describe(Version, Tag, commit, modified)
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
return b
})
// parseDescribe splits git describe --long output, if it is of commit.
func parseDescribe(d, commit string) (since string, ahead int) {
m := describeRe.FindStringSubmatch(d)
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
return "", 0
}
n, _ := strconv.Atoi(m[2])
return m[1], n
}
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
func describe(version, tag, commit string, modified bool) Build {
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
switch {
case tag != "":
b.Modified = false // the Makefile only sets Tag for clean trees
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
case commit != "":
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
if modified {
b.Label += "-modified"
}
default:
b.Label, b.URL = version, Repo
}
return b
}
// Info describes this build.
func Info() Build { return info() }
// String is the version for logs and 'version' commands. Only a release
// build gives a version ("0.2.0"); any other build says it's a
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
func String() string { return Info().String() }
func (b Build) String() string {
switch {
case b.Tag != "":
return b.Version
case b.Commit == "":
// No git information (built from a source archive).
return b.Version + " (source build)"
}
s := "dev " + b.Label
switch {
case b.Since == "":
case b.Ahead == 1:
s += " (1 commit after " + b.Since + ")"
case b.Ahead > 1:
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
case b.Modified:
s += " (" + b.Since + " with uncommitted changes)"
default:
s += " (at " + b.Since + ")"
}
return s
}
// Short is the version for a User-Agent: the version of a release,
// otherwise the label ("a1b2c3d4e5f6").
func Short() string {
if b := Info(); b.Tag == "" && b.Commit != "" {
return b.Label
}
return Version
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
Executable
+189
View File
@@ -0,0 +1,189 @@
#!/bin/bash
# End-to-end test of Guard with Housecarl and real traffic: a router
# container (Debian 13, nftables 1.1.3) runs housecarld between an
# "internet" network and a LAN; a server on the LAN runs Guard over a log
# file; a host on the internet misbehaves. Checks that Guard bans it at
# the router (one port, for the server alone), that never_ban holds, that
# bans wait while Housecarl is down, and that Guard on the router bans
# scanners from Housecarl's own log of dropped packets.
#
# Needs Docker and ../housecarl (its image housecarl-nft, made by its
# scripts/e2e.sh if missing). Run from the repository root:
#
# make e2e or scripts/e2e.sh
#
# KEEP=1 leaves the containers and networks running.
set -u
ROOT=$(cd "$(dirname "$0")/.." && pwd)
HC=${HOUSECARL_SRC:-$ROOT/../housecarl}
P=${E2E_PREFIX:-gde2e}
IMAGE=${E2E_IMAGE:-housecarl-nft}
WORK=${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/guard-e2e.XXXXXX")}
docker info >/dev/null 2>&1 || { echo "Docker isn't reachable (set DOCKER_HOST?)"; exit 2; }
docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "no image $IMAGE: run ../housecarl's make e2e once to make it"; exit 2; }
(cd "$ROOT" && CGO_ENABLED=0 go build -o "$WORK/guard" ./cmd/guard) || exit 1
(cd "$HC" && CGO_ENABLED=0 go build -o "$WORK/housecarld" ./cmd/housecarld && CGO_ENABLED=0 go build -o "$WORK/hc" ./cmd/hc) || exit 1
cleanup() {
if [ "${KEEP:-}" = 1 ]; then echo "left running: containers ${P}-*, networks ${P}-*, files in $WORK"; return; fi
docker exec ${P}-router sh -c 'rm -rf /w/data /w/*.log /w/state*' >/dev/null 2>&1
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
docker network rm ${P}-wan ${P}-lan >/dev/null 2>&1
rm -rf "$WORK"
}
trap cleanup EXIT
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
docker network rm ${P}-wan ${P}-lan >/dev/null 2>&1
# The internet 198.18.2.0/24, the LAN 10.89.10.0/24; the router has .1 on
# the LAN, .2 on the internet; the server is 10.89.10.10.
docker network create --subnet 198.18.2.0/24 --gateway 198.18.2.254 ${P}-wan >/dev/null || exit 1
docker network create --subnet 10.89.10.0/24 --gateway 10.89.10.254 ${P}-lan >/dev/null || exit 1
WAN4=198.18.2.2 LAN4=10.89.10.1 SRV=10.89.10.10 NET=198.18.2.10 NET2=198.18.2.11
docker run -d --name ${P}-router --hostname router --cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \
--network ${P}-wan --ip $WAN4 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
docker network connect --ip $LAN4 ${P}-lan ${P}-router || exit 1
docker exec ${P}-router sh -c 'ip link set eth0 down && ip link set eth0 name wan && ip link set wan up && ip link set eth1 down && ip link set eth1 name lan && ip link set lan up && ip route replace default via 198.18.2.254' || exit 1
host() { # NAME NETWORK IP GATEWAY
docker run -d --name ${P}-$1 --cap-add NET_ADMIN --network ${P}-$2 --ip $3 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
docker exec ${P}-$1 ip route replace default via $4 || exit 1
}
host srv lan $SRV $LAN4
host net wan $NET 198.18.2.254
docker exec ${P}-net sh -c "ip addr add $NET2/24 dev eth0 && ip route replace 10.89.10.0/24 via $WAN4"
# The server: "ssh" on 22 and "web" on 80.
docker exec -d ${P}-srv sh -c 'while true; do echo ssh | nc -N -l 0.0.0.0 22; done'
docker exec -d ${P}-srv sh -c 'while true; do echo web | nc -N -l 0.0.0.0 80; done'
PASS=0; FAIL=0
ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
run() {
local desc=$1 want=$2; shift 2
OUT=$("$@" 2>&1); local rc=$?
if [ "$rc" = "$want" ]; then ok "$desc (rc=$rc)"; else bad "$desc: rc=$rc want $want"; echo "$OUT" | sed 's/^/ /'; fi
}
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; fi; }
# eventually DESC CMD...: retries for 15 seconds until CMD succeeds.
eventually() {
local desc=$1; shift
for i in $(seq 1 30); do OUT=$("$@" 2>&1) && { ok "$desc"; return; }; sleep 0.5; done
bad "$desc"; echo "$OUT" | sed 's/^/ /'
}
R() { docker exec ${P}-router "$@"; }
H() { docker exec ${P}-router /w/hc "$@"; }
connect() { docker exec ${P}-net nc -w 3 ${2:+-s $2} $SRV $1 </dev/null; }
fails() { # LINES SOURCE: sshd's failed logins
for i in $(seq 1 $1); do echo "$(date '+%b %e %T') srv sshd-session[$i]: Failed password for invalid user admin from $2 port 4$i ssh2"; done >> "$WORK/auth.log"
}
cat > "$WORK/housecarl.yaml" <<'EOF'
listen: ["0.0.0.0:8070"]
database: /w/data/housecarl.db
ruleset: /w/data/ruleset.nft
log: {level: debug}
stats: {enabled: false}
EOF
start_hc() { R sh -c "(/w/housecarld -c /w/housecarl.yaml serve >> /w/housecarl.log 2>&1 &); for i in \$(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1"; }
stop_hc() { R sh -c 'kill $(pidof housecarld)'; sleep 1; }
# ---- Housecarl: a router with a ban list for the server
run "housecarld starts" 0 start_hc
run "zones" 0 sh -c "docker exec ${P}-router /w/hc zone add wan --interface wan --external && docker exec ${P}-router /w/hc zone add lan --interface lan"
run "the API from the LAN" 0 H rule add input --in lan --service tcp/8070
run "the internet reaches the server's ssh and web" 0 H rule add forward --in wan --to $SRV --service tcp/22 --service tcp/80
run "the LAN goes out" 0 H rule add forward --in lan --out wan
run "a ban list for the server" 0 H banlist add srv --scope hosts --host $SRV
run "a user for the server's Guard" 0 H user add guard-srv --no-password --grant bans:write:srv
R sh -c "/w/hc token create srv --user guard-srv > /w/token 2>/dev/null"
run "its token" 0 test -s "$WORK/token"
run "ssh from the internet" 0 connect 22
contains "^ssh$"
# ---- Guard on the server, over a log file
: > "$WORK/auth.log"
cat > "$WORK/guard.yaml" <<EOF
housecarl: {url: "http://$LAN4:8070", token_file: /w/token, list: srv}
never_ban: [$NET2]
defaults: {hits: 3, within: 10m, ban: 1h}
watch:
- name: ssh
preset: sshd
file: /w/auth.log
ban: {protocol: tcp, port: ssh}
state: /w/state.json
socket: /w/guard.sock
log: {level: debug}
EOF
run "guard check" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml check
contains "ban list srv: ok (scope hosts, IPv6 banned by /64)"
docker exec -d ${P}-srv sh -c '/w/guard -c /w/guard.yaml run >> /w/guard.log 2>&1'
sleep 2
fails 2 $NET
sleep 2
run "two failures: no ban yet" 0 H bans srv
not_contains "$NET"
fails 1 $NET
eventually "the third: banned at the router" sh -c "docker exec ${P}-router /w/hc bans srv | grep -q '$NET *tcp/22 .*guard-srv *guard/ssh'"
run "ssh from it is cut" 1 connect 22
run "its web isn't (tcp/22 only)" 0 connect 80
contains "^web$"
run "guard status" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml status
contains "ssh *file /w/auth.log *3 *3 *1"
contains "$NET *srv *tcp/ssh *1h *banned"
fails 5 $NET2
sleep 3
run "never_ban: not banned" 0 H bans srv
not_contains "$NET2"
run "ssh from it still works" 0 connect 22 $NET2
contains "^ssh$"
run "guard test over the log" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml test ssh /w/auth.log
contains "would ban 1"
contains "never banned: in never_ban $NET2/32"
run "guard unban" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml unban $NET
contains "1 bans lifted"
run "ssh again" 0 connect 22
contains "^ssh$"
# ---- Housecarl away: the ban waits
stop_hc
fails 3 $NET
sleep 3
run "the ban waits" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml status
contains "1 bans wait for Housecarl"
run "housecarld starts again" 0 start_hc
eventually "the ban that waited arrives" sh -c "docker exec ${P}-router /w/hc bans srv | grep -q '$NET *tcp/22'"
run "its time grew: a repeat offender" 0 H bans srv
contains "$NET *tcp/22 *1h59m\|$NET *tcp/22 *2h"
run "ssh from it is cut again" 1 connect 22
run "lift it" 0 H unban srv $NET
# ---- Guard on the router: scans in Housecarl's log
run "a ban list for the router" 0 H banlist add edge --scope everything
run "a logged drop for a scanned port" 0 H rule add input --in wan --service tcp/9999 -a drop --log
run "a user for the router's Guard" 0 H user add guard-edge --no-password --grant bans:write:edge --grant log:read
R sh -c "/w/hc token create edge --user guard-edge > /w/token-edge 2>/dev/null"
cat > "$WORK/guard-edge.yaml" <<EOF
housecarl: {socket: /run/housecarl/housecarl.sock, token_file: /w/token-edge, list: edge}
watch:
- {name: scans, preset: housecarl-drops, hits: 4, within: 1m, ban: {time: 1d}}
state: /w/state-edge.json
socket: /w/guard-edge.sock
EOF
run "guard check on the router" 0 R /w/guard -c /w/guard-edge.yaml check
contains "Housecarl's log (housecarl-drops): ok"
docker exec -d ${P}-router sh -c '/w/guard -c /w/guard-edge.yaml run >> /w/guard-edge.log 2>&1'
sleep 2
for i in 1 2 3 4; do docker exec ${P}-net nc -w 1 $WAN4 9999 </dev/null >/dev/null 2>&1; done
eventually "the scanner is banned from everything" sh -c "docker exec ${P}-router /w/hc bans edge | grep -q '$NET *all .*guard/scans'"
run "and can't reach the server at all" 1 connect 80
echo
echo "RESULT: $PASS passed, $FAIL failed"
[ $FAIL = 0 ]
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# Builds Guard's Debian package, dist/guard_<version>_<arch>.deb:
#
# /usr/bin/guard and its bash completion
# /usr/lib/systemd/system/guard.service, /usr/lib/sysusers.d/guard.conf
# /usr/share/doc/guard/ (README, CHANGELOG, docs)
#
# Its scripts (deploy/debian) create the guard user and an example
# /etc/guard/guard.yaml on the first install, restart a running guard on
# upgrades, and stop it on removal. No file in /etc belongs to the
# package, so an upgrade never asks about configuration files.
#
# Run through 'make deb'; needs only dpkg-deb, no root and no debhelper.
set -eu
: "${DEB_ARCH:?}" "${DEB_VERSION:?}" "${VERSION:?}" "${LDFLAGS:?}"
GOFLAGS=${GOFLAGS:-}
goarm=
case "$DEB_ARCH" in
amd64) goarch=amd64 ;;
arm64) goarch=arm64 ;;
armhf) goarch=arm goarm=7 ;;
i386) goarch=386 ;;
ppc64el) goarch=ppc64le ;;
riscv64) goarch=riscv64 ;;
*) echo "mkdeb: unsupported architecture $DEB_ARCH" >&2; exit 1 ;;
esac
if [ -z "${DEB_MAINTAINER:-}" ]; then
name=$(git config user.name 2>/dev/null || true)
email=$(git config user.email 2>/dev/null || true)
DEB_MAINTAINER="${name:-Guard packager} <${email:-root@localhost}>"
fi
work=dist/deb/${DEB_VERSION}_${DEB_ARCH}
root=$work/guard
rm -rf "$work"
rm -f dist/guard_*_"$DEB_ARCH".deb
mkdir -p "$root"
echo "mkdeb: building for linux/$goarch"
export GOFLAGS
GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/bin/guard" ./cmd/guard
make --no-print-directory install-files DESTDIR="$root" PREFIX=/usr UNITDIR=/usr/lib/systemd/system GUARD_BIN="$work/bin/guard" >/dev/null
mkdir -p "$root/DEBIAN"
for f in postinst prerm postrm; do
install -m 0755 "deploy/debian/$f" "$root/DEBIAN/$f"
done
indent() { sed -e 's/^$/./' -e 's/^/ /' "$1"; }
{
echo "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
echo "Upstream-Name: guard"
echo
echo "Files: *"
echo "Copyright: $(sed -n 's/^Copyright (c) //p' LICENSE)"
echo "License: Expat"
indent LICENSE
} > "$root/usr/share/doc/guard/copyright"
rm -f "$root/usr/share/doc/guard/LICENSE"
find "$root" -type d -exec chmod 0755 {} +
chmod -R go-w "$root"
size=$(du -sk --exclude=DEBIAN "$root" | cut -f1)
sed -e "s/@VERSION@/$DEB_VERSION/g" -e "s/@ARCH@/$DEB_ARCH/" -e "s/@SIZE@/$size/" \
-e "s|@MAINTAINER@|$DEB_MAINTAINER|" deploy/debian/control.in > "$root/DEBIAN/control"
(cd "$root" && find . -path ./DEBIAN -prune -o -type f -print | sed 's|^\./||' | sort | xargs md5sum) > "$root/DEBIAN/md5sums"
chmod 0644 "$root/DEBIAN/control" "$root/DEBIAN/md5sums"
out=dist/guard_${DEB_VERSION}_${DEB_ARCH}.deb
dpkg-deb --root-owner-group -Zxz --build "$root" "$out" >/dev/null
echo "mkdeb: wrote $out"
dpkg-deb --info "$out" | sed -n 's/^ \(Package\|Version\|Architecture\|Installed-Size\|Depends\):/ \1:/p'
rm -rf dist/deb
+4
View File
@@ -0,0 +1,4 @@
203.0.113.9 client @0x7f3a1c0e4168 203.0.113.9#41234 (example.com): view external: zone transfer 'example.com/AXFR/IN' denied
2001:db8::9 client @0x7f3a1c0e4168 2001:db8::9#41234 (example.com): zone transfer 'example.com/AXFR/IN' denied
- client @0x7f3a1c0e4168 203.0.113.9#41234 (example.com): view external: query (cache) 'example.com/ANY/IN' denied
- client @0x7f3a1c0e4168 10.0.10.2#41234 (example.com): transfer of 'example.com/IN': AXFR started
+4
View File
@@ -0,0 +1,4 @@
203.0.113.9 imap-login: Disconnected: Connection closed (auth failed, 3 attempts in 12 secs): user=<admin>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, TLS, session=<abc>
203.0.113.9 pop3-login: Aborted login (auth failed, 1 attempts in 2 secs): user=<test>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session=<abc>
203.0.113.9 imap-login: Disconnected (auth failed, 1 attempts in 2 secs): user=<a>, rip=8.8.8.8, x>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session=<abc>
- imap-login: Login: user=<adam>, method=PLAIN, rip=10.0.10.20, lip=10.0.10.25, mpid=1234, TLS
+6
View File
@@ -0,0 +1,6 @@
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
2001:db8::9 time=2026-10-22T10:00:00.000Z level=WARN msg="token refused" ip=2001:db8::9 reason="wrong token secret"
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user="x ip=8.8.8.8" reason="wrong username or password"
203.0.113.9 {"time":"2026-10-22T10:00:00Z","level":"WARN","msg":"login failed","ip":"203.0.113.9","user":"admin","reason":"wrong username or password"}
- time=2026-10-22T10:00:00.000Z level=INFO msg="configuration changed" history=3 user=admin
- time=2026-10-22T10:00:00.000Z level=WARN msg="token used with its old secret" ip=203.0.113.9
+6
View File
@@ -0,0 +1,6 @@
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
2001:db8::9 time=2026-10-22T10:00:00.000Z level=WARN msg="token refused" ip=2001:db8::9 reason="wrong token secret"
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user="x ip=8.8.8.8" reason="wrong username or password"
203.0.113.9 {"time":"2026-10-22T10:00:00Z","level":"WARN","msg":"login failed","ip":"203.0.113.9","user":"admin","reason":"wrong username or password"}
- time=2026-10-22T10:00:00.000Z level=INFO msg="configuration changed" history=3 user=admin
- time=2026-10-22T10:00:00.000Z level=WARN msg="token used with its old secret" ip=203.0.113.9
+3
View File
@@ -0,0 +1,3 @@
203.0.113.9 2026/10/22 10:00:00 [error] 1234#1234: *5 user "admin" was not found in "/etc/nginx/.htpasswd", client: 203.0.113.9, server: example.com, request: "GET / HTTP/1.1", host: "example.com"
203.0.113.9 2026/10/22 10:00:00 [error] 1234#1234: *5 user "adam": password mismatch, client: 203.0.113.9, server: example.com, request: "GET / HTTP/1.1"
- 2026/10/22 10:00:00 [error] 1234#1234: *5 open() "/var/www/favicon.ico" failed (2: No such file or directory), client: 203.0.113.9, server: example.com
+3
View File
@@ -0,0 +1,3 @@
203.0.113.9 warning: unknown[203.0.113.9]: SASL LOGIN authentication failed: UGFzc3dvcmQ6
203.0.113.9 warning: scanner.example.net[203.0.113.9]: SASL PLAIN authentication failed: authentication failure
- warning: hostname 203-0-113-9.example.net does not resolve to address 203.0.113.9
+15
View File
@@ -0,0 +1,15 @@
# Each line: the address a match should give (- for none), a tab, the log line.
203.0.113.9 Failed password for root from 203.0.113.9 port 51234 ssh2
203.0.113.9 Failed password for invalid user admin from 203.0.113.9 port 51234 ssh2
2001:db8::9 Failed publickey for git from 2001:db8::9 port 4022 ssh2
203.0.113.9 Oct 22 10:00:01 web sshd-session[1234]: Failed password for invalid user oracle from 203.0.113.9 port 4022 ssh2
203.0.113.9 Invalid user test from 203.0.113.9 port 40022
203.0.113.9 Connection closed by invalid user admin 203.0.113.9 port 40022 [preauth]
203.0.113.9 Disconnected from authenticating user root 203.0.113.9 port 40022 [preauth]
203.0.113.9 error: maximum authentication attempts exceeded for root from 203.0.113.9 port 40022 ssh2 [preauth]
203.0.113.9 Unable to negotiate with 203.0.113.9 port 40022: no matching key exchange method found. Their offer: diffie-hellman-group1-sha1
203.0.113.9 banner exchange: Connection from 203.0.113.9 port 40022: invalid format
203.0.113.10 Failed password for invalid user x from 198.51.100.1 port 22 from 203.0.113.10 port 4444 ssh2
- Accepted publickey for adam from 10.0.10.20 port 51000 ssh2: ED25519 SHA256:xyz
- Received disconnect from 203.0.113.9 port 40022:11: disconnected by user
- Server listening on 0.0.0.0 port 22.
+6
View File
@@ -0,0 +1,6 @@
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
2001:db8::9 time=2026-10-22T10:00:00.000Z level=WARN msg="token refused" ip=2001:db8::9 reason="wrong token secret"
203.0.113.9 time=2026-10-22T10:00:00.000Z level=WARN msg="login failed" ip=203.0.113.9 user="x ip=8.8.8.8" reason="wrong username or password"
203.0.113.9 {"time":"2026-10-22T10:00:00Z","level":"WARN","msg":"login failed","ip":"203.0.113.9","user":"admin","reason":"wrong username or password"}
- time=2026-10-22T10:00:00.000Z level=INFO msg="configuration changed" history=3 user=admin
- time=2026-10-22T10:00:00.000Z level=WARN msg="token used with its old secret" ip=203.0.113.9