From d06bf8d5318090e0179fba19c426a97a22c3e110 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 22 Oct 2026 16:29:37 +0100 Subject: [PATCH] Guard: watch logs, ban misbehaving addresses in Housecarl Files and the journal, patterns with one , 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 --- .gitignore | 3 + AGENTS.md | 59 +++ CHANGELOG.md | 28 ++ CLAUDE.md | 8 + LICENSE | 21 + Makefile | 85 ++++ README.md | 67 +++ VERSION | 1 + cmd/guard/main.go | 690 ++++++++++++++++++++++++++++++ deploy/debian/control.in | 18 + deploy/debian/postinst | 26 ++ deploy/debian/postrm | 17 + deploy/debian/prerm | 10 + deploy/systemd/guard.service | 55 +++ deploy/sysusers.d/guard.conf | 3 + docs/cli.md | 23 + docs/configuration.md | 126 ++++++ docs/install.md | 72 ++++ docs/presets.md | 85 ++++ go.mod | 13 + go.sum | 12 + internal/bounty/bounty.go | 120 ++++++ internal/bounty/bounty_test.go | 30 ++ internal/config/config.go | 442 +++++++++++++++++++ internal/config/config_test.go | 120 ++++++ internal/config/example.yaml | 71 +++ internal/config/presets.go | 36 ++ internal/config/presets.yaml | 70 +++ internal/daemon/daemon.go | 541 +++++++++++++++++++++++ internal/daemon/daemon_test.go | 179 ++++++++ internal/housecarl/client.go | 253 +++++++++++ internal/match/match.go | 89 ++++ internal/match/match_test.go | 50 +++ internal/source/source.go | 423 ++++++++++++++++++ internal/source/source_test.go | 158 +++++++ internal/state/state.go | 151 +++++++ internal/version/version.go | 150 +++++++ scripts/e2e.sh | 189 ++++++++ scripts/mkdeb.sh | 73 ++++ testdata/presets/bind-xfr.txt | 4 + testdata/presets/dovecot.txt | 4 + testdata/presets/housecarl.txt | 6 + testdata/presets/jarl.txt | 6 + testdata/presets/nginx-auth.txt | 3 + testdata/presets/postfix-sasl.txt | 3 + testdata/presets/sshd.txt | 15 + testdata/presets/steward.txt | 6 + 47 files changed, 4614 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 cmd/guard/main.go create mode 100644 deploy/debian/control.in create mode 100755 deploy/debian/postinst create mode 100755 deploy/debian/postrm create mode 100755 deploy/debian/prerm create mode 100644 deploy/systemd/guard.service create mode 100644 deploy/sysusers.d/guard.conf create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/install.md create mode 100644 docs/presets.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bounty/bounty.go create mode 100644 internal/bounty/bounty_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/example.yaml create mode 100644 internal/config/presets.go create mode 100644 internal/config/presets.yaml create mode 100644 internal/daemon/daemon.go create mode 100644 internal/daemon/daemon_test.go create mode 100644 internal/housecarl/client.go create mode 100644 internal/match/match.go create mode 100644 internal/match/match_test.go create mode 100644 internal/source/source.go create mode 100644 internal/source/source_test.go create mode 100644 internal/state/state.go create mode 100644 internal/version/version.go create mode 100755 scripts/e2e.sh create mode 100755 scripts/mkdeb.sh create mode 100644 testdata/presets/bind-xfr.txt create mode 100644 testdata/presets/dovecot.txt create mode 100644 testdata/presets/housecarl.txt create mode 100644 testdata/presets/jarl.txt create mode 100644 testdata/presets/nginx-auth.txt create mode 100644 testdata/presets/postfix-sasl.txt create mode 100644 testdata/presets/sshd.txt create mode 100644 testdata/presets/steward.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba6b331 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/dist/ +/guard diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6450c18 --- /dev/null +++ b/AGENTS.md @@ -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 ``; `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 + `` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eee497b --- /dev/null +++ b/CHANGELOG.md @@ -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 ``, 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a26ca4 --- /dev/null +++ b/CLAUDE.md @@ -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 `. +- Keep temporary scripts and test servers' work directories in the + session scratchpad, not in the repo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2eda393 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a60af62 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc133fb --- /dev/null +++ b/README.md @@ -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: ['^ \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '] + hits: 10 + ban: {time: 6h} +``` + +* **One pattern placeholder, ``.** 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. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d4d124 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0-dev diff --git a/cmd/guard/main.go b/cmd/guard/main.go new file mode 100644 index 0000000..bc7511e --- /dev/null +++ b/cmd/guard/main.go @@ -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()) + }, + } +} diff --git a/deploy/debian/control.in b/deploy/debian/control.in new file mode 100644 index 0000000..6a09baa --- /dev/null +++ b/deploy/debian/control.in @@ -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. diff --git a/deploy/debian/postinst b/deploy/debian/postinst new file mode 100755 index 0000000..daea00b --- /dev/null +++ b/deploy/debian/postinst @@ -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 diff --git a/deploy/debian/postrm b/deploy/debian/postrm new file mode 100755 index 0000000..fa21555 --- /dev/null +++ b/deploy/debian/postrm @@ -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 diff --git a/deploy/debian/prerm b/deploy/debian/prerm new file mode 100755 index 0000000..f7056ba --- /dev/null +++ b/deploy/debian/prerm @@ -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 diff --git a/deploy/systemd/guard.service b/deploy/systemd/guard.service new file mode 100644 index 0000000..eb02303 --- /dev/null +++ b/deploy/systemd/guard.service @@ -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 diff --git a/deploy/sysusers.d/guard.conf b/deploy/sysusers.d/guard.conf new file mode 100644 index 0000000..b668c38 --- /dev/null +++ b/deploy/sysusers.d/guard.conf @@ -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 diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..70c8fee --- /dev/null +++ b/docs/cli.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8089181 --- /dev/null +++ b/docs/configuration.md @@ -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: + - '^ \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 `` 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` | diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..1877899 --- /dev/null +++ b/docs/install.md @@ -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. diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000..b7440a4 --- /dev/null +++ b/docs/presets.md @@ -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 `` once, where the address is: + +```yaml +match: + - '^ \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 ' + - 'authentication failure; .* rhost=(?: |$)' +``` + +`` 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 `` 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3d899dd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7e937c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/bounty/bounty.go b/internal/bounty/bounty.go new file mode 100644 index 0000000..4b70647 --- /dev/null +++ b/internal/bounty/bounty.go @@ -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() +} diff --git a/internal/bounty/bounty_test.go b/internal/bounty/bounty_test.go new file mode 100644 index 0000000..6bcc21c --- /dev/null +++ b/internal/bounty/bounty_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a460652 --- /dev/null +++ b/internal/config/config.go @@ -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 ; 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8007f82 --- /dev/null +++ b/internal/config/config_test.go @@ -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: ['']}]", "one source"}, + {base + "watch: [{name: a, file: /x}]", "no match patterns"}, + {base + "watch: [{name: a, source: housecarl-drops, match: ['']}]", "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 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 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) + } + } +} diff --git a/internal/config/example.yaml b/internal/config/example.yaml new file mode 100644 index 0000000..53f987d --- /dev/null +++ b/internal/config/example.yaml @@ -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: is where the address is. + # - name: web-login + # file: /var/log/nginx/access.log + # match: + # - '^ \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} diff --git a/internal/config/presets.go b/internal/config/presets.go new file mode 100644 index 0000000..ee0d245 --- /dev/null +++ b/internal/config/presets.go @@ -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) } diff --git a/internal/config/presets.yaml b/internal/config/presets.yaml new file mode 100644 index 0000000..bfa670f --- /dev/null +++ b/internal/config/presets.yaml @@ -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 port \d+(?: ssh2)?$' + - '(?:^|: )Invalid user .* from port \d+$' + - '(?:^|: )(?:Connection (?:closed|reset)|Disconnected) (?:by|from) (?:invalid|authenticating) user .* port \d+ \[preauth\]$' + - '(?:^|: )error: maximum authentication attempts exceeded for .* from port \d+ ssh2 \[preauth\]$' + - '(?:^|: )Unable to negotiate with port \d+: no matching (?:key exchange method|host key type|cipher|MAC) found' + - '(?:^|: )banner exchange: Connection from 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= ' + - '"msg":"(?:login failed|token refused)","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= ' + - '"msg":"(?:login failed|token refused)","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= ' + - '"msg":"(?:login failed|token refused)","ip":""' + +bind-xfr: + description: zone transfers BIND refused (over TCP, so the address is real) + journal: {identifier: named} + match: + - 'client @0x[0-9a-f]+ #\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]+\[\]: 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=, 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: , server: ' + +housecarl-drops: + description: packets Housecarl's rules dropped and logged (on the firewall; TCP only by default) + source: housecarl-drops diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..ed57b36 --- /dev/null +++ b/internal/daemon/daemon.go @@ -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 "" +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..4416e22 --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -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) + } +} diff --git a/internal/housecarl/client.go b/internal/housecarl/client.go new file mode 100644 index 0000000..248f488 --- /dev/null +++ b/internal/housecarl/client.go @@ -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 +} diff --git a/internal/match/match.go b/internal/match/match.go new file mode 100644 index 0000000..936b5ec --- /dev/null +++ b/internal/match/match.go @@ -0,0 +1,89 @@ +// Package match finds the address in a log line: patterns are regular +// expressions (Go's, RE2) with one , 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 = "" + +// 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[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 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 +} diff --git a/internal/match/match_test.go b/internal/match/match_test.go new file mode 100644 index 0000000..946d9c9 --- /dev/null +++ b/internal/match/match_test.go @@ -0,0 +1,50 @@ +package match + +import ( + "net/netip" + "testing" +) + +func TestMatch(t *testing.T) { + m, err := Compile([]string{`from port`, `client: ,`, `^conn $`}, []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{" and "}, nil); err == nil { + t.Error("two accepted") + } + if _, err := Compile([]string{" (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) + } +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..2e29e3a --- /dev/null +++ b/internal/source/source.go @@ -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) +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..20fcebf --- /dev/null +++ b/internal/source/source_test.go @@ -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): + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..e360a79 --- /dev/null +++ b/internal/state/state.go @@ -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 + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..b566311 --- /dev/null +++ b/internal/version/version.go @@ -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 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 +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..345f6a4 --- /dev/null +++ b/scripts/e2e.sh @@ -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 > "$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" <> /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" <> /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 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 ] diff --git a/scripts/mkdeb.sh b/scripts/mkdeb.sh new file mode 100755 index 0000000..6ca5691 --- /dev/null +++ b/scripts/mkdeb.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Builds Guard's Debian package, dist/guard__.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 diff --git a/testdata/presets/bind-xfr.txt b/testdata/presets/bind-xfr.txt new file mode 100644 index 0000000..9a86402 --- /dev/null +++ b/testdata/presets/bind-xfr.txt @@ -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 diff --git a/testdata/presets/dovecot.txt b/testdata/presets/dovecot.txt new file mode 100644 index 0000000..41e6904 --- /dev/null +++ b/testdata/presets/dovecot.txt @@ -0,0 +1,4 @@ +203.0.113.9 imap-login: Disconnected: Connection closed (auth failed, 3 attempts in 12 secs): user=, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, TLS, session= +203.0.113.9 pop3-login: Aborted login (auth failed, 1 attempts in 2 secs): user=, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session= +203.0.113.9 imap-login: Disconnected (auth failed, 1 attempts in 2 secs): user=, rip=8.8.8.8, x>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session= +- imap-login: Login: user=, method=PLAIN, rip=10.0.10.20, lip=10.0.10.25, mpid=1234, TLS diff --git a/testdata/presets/housecarl.txt b/testdata/presets/housecarl.txt new file mode 100644 index 0000000..ca4c7e6 --- /dev/null +++ b/testdata/presets/housecarl.txt @@ -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 diff --git a/testdata/presets/jarl.txt b/testdata/presets/jarl.txt new file mode 100644 index 0000000..ca4c7e6 --- /dev/null +++ b/testdata/presets/jarl.txt @@ -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 diff --git a/testdata/presets/nginx-auth.txt b/testdata/presets/nginx-auth.txt new file mode 100644 index 0000000..fcd1a1b --- /dev/null +++ b/testdata/presets/nginx-auth.txt @@ -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 diff --git a/testdata/presets/postfix-sasl.txt b/testdata/presets/postfix-sasl.txt new file mode 100644 index 0000000..c9e3c05 --- /dev/null +++ b/testdata/presets/postfix-sasl.txt @@ -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 diff --git a/testdata/presets/sshd.txt b/testdata/presets/sshd.txt new file mode 100644 index 0000000..63debe2 --- /dev/null +++ b/testdata/presets/sshd.txt @@ -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. diff --git a/testdata/presets/steward.txt b/testdata/presets/steward.txt new file mode 100644 index 0000000..ca4c7e6 --- /dev/null +++ b/testdata/presets/steward.txt @@ -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