commit d06bf8d5318090e0179fba19c426a97a22c3e110 Author: abrown38 Date: Thu Oct 22 16:29:37 2026 +0100 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 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