Guard: watch logs, ban misbehaving addresses in Housecarl
Files and the journal, patterns with one <IP>, bounties within a window, bans in a Housecarl ban list with repeat offenders' bans growing; presets for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot, nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban; a Debian package with a sandboxed unit; an end-to-end test with Housecarl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d06bf8d531
47 files changed
+4614
No files matched your search
@@ -0,0 +1,3 @@
|
||||
/bin/
|
||||
/dist/
|
||||
/guard
|
||||
@@ -0,0 +1,59 @@
|
||||
# Working on Guard
|
||||
|
||||
Notes for coding agents (and people) changing this repository.
|
||||
|
||||
Guard watches logs and bans misbehaving addresses in Housecarl's ban
|
||||
lists (`../housecarl`, its docs/bans.md). It is the fourth of a set with
|
||||
Jarl (`../jarl`), Steward (`../steward`) and Housecarl, and copies their
|
||||
ways: Go, `CGO_ENABLED=0`, one YAML file, the same release process. It
|
||||
has no web UI and no database: bans are shown by Housecarl.
|
||||
|
||||
## Layout
|
||||
|
||||
* `cmd/guard`: the one binary (cobra): `run` is the daemon, the rest are
|
||||
tools.
|
||||
* `internal/config`: guard.yaml, with `presets.yaml` and `example.yaml`
|
||||
embedded. A watch's preset is resolved here (`Watch.resolve`).
|
||||
* `internal/match`: patterns with `<IP>`; `Group` (IPv6 by prefix).
|
||||
* `internal/source`: files (polled tail -F), the journal (journalctl
|
||||
subprocess, `GUARD_JOURNALCTL` for tests), Housecarl's log.
|
||||
* `internal/bounty`: per-watch counts within a window.
|
||||
* `internal/daemon`: lines → matches → bounties → bans, the retry queue,
|
||||
the status socket, `never_ban`.
|
||||
* `internal/housecarl`: the API client (ban lists, bans, the log).
|
||||
* `internal/state`: the JSON state file.
|
||||
|
||||
## Ground rules
|
||||
|
||||
* **Never trust a client's text for the address.** Preset patterns put
|
||||
`<IP>` where the program writes the address, after anything a client
|
||||
chose, anchored at the line's end where the format allows. Every
|
||||
preset has sample lines in `testdata/presets/NAME.txt` (the address
|
||||
to find, or `-`), including an attempt to plant an address;
|
||||
`TestPresets` runs them.
|
||||
* **No timestamp parsing.** Lines count when read (the journal's and
|
||||
Housecarl's carry their own time).
|
||||
* **Never ban on spoofable evidence by default**: UDP sources can be
|
||||
faked (`housecarl-drops` is TCP unless told otherwise; no preset for
|
||||
BIND's UDP refusals).
|
||||
* **Every user-visible change updates `CHANGELOG.md`** under
|
||||
`## [Unreleased]`, and the matching `docs/` page.
|
||||
* `make test` runs `go vet`, a `gofmt -l` check and the tests.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
make test
|
||||
make e2e # with ../housecarl's lab: needs Docker (DOCKER_HOST=unix:///var/run/docker.sock here)
|
||||
```
|
||||
|
||||
## Git and releases
|
||||
|
||||
As in Housecarl: commit only when asked, never push; branches
|
||||
`dev-X.Y.Z`; a release is a commit "Release X" setting `VERSION`, with
|
||||
the annotated tag `vX` ("Guard X").
|
||||
|
||||
## Shell hygiene
|
||||
|
||||
Never kill processes by pattern (`pkill -f`): the pattern matches the
|
||||
shell running it. Find the PID and kill that.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Guard are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- First version. Guard watches log files (followed through rotation and
|
||||
truncation) and the systemd journal (by unit and syslog identifier),
|
||||
matches lines with patterns holding one `<IP>`, counts each address's
|
||||
bounty within a window, and bans an address that reaches a watch's
|
||||
hits in a Housecarl ban list (Housecarl 0.2): of every port, or of the
|
||||
watch's protocol and ports, for a time that grows for repeat
|
||||
offenders. IPv6 addresses are counted and banned by the ban list's
|
||||
prefix (/64). Never banned: `never_ban` (addresses, prefixes, host
|
||||
names), loopback, link-local, multicast; Housecarl adds its own. Bans
|
||||
wait while Housecarl can't be reached, across restarts.
|
||||
- Presets: `sshd`, `jarl`, `housecarl`, `steward`, `bind-xfr`,
|
||||
`postfix-sasl`, `dovecot`, `nginx-auth`, `housecarl-drops`
|
||||
(Housecarl's log of dropped packets, TCP only by default).
|
||||
- Commands: `guard run`, `check`, `config [--example]`, `presets`,
|
||||
`test` (a watch's patterns over a log), `status` (through a local
|
||||
socket), `bans`, `ban`, `unban`, `version`.
|
||||
- A Debian package with a sandboxed systemd unit, as the `guard` user in
|
||||
the `adm` and `systemd-journal` groups.
|
||||
@@ -0,0 +1,8 @@
|
||||
@AGENTS.md
|
||||
|
||||
## Claude Code specifics
|
||||
|
||||
- End commit messages with a `Co-Authored-By:` trailer naming the model,
|
||||
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Keep temporary scripts and test servers' work directories in the
|
||||
session scratchpad, not in the repo.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
# Guard
|
||||
|
||||
Guard watches logs and bans the addresses that misbehave in them, at the
|
||||
firewall. It reads log files and the systemd journal, counts each
|
||||
address's **bounty** (its matches within a while), and once that reaches
|
||||
a watch's limit bans the address in a ban list of
|
||||
[Housecarl](https://gitea.adbraun.lol/adam/housecarl), the firewall
|
||||
manager. The ban list says what a banned address is kept from: the
|
||||
firewall itself, some hosts behind it, or everything. Guard doesn't need
|
||||
to know anything about the firewall.
|
||||
|
||||
It is the fourth of a set with Jarl (BIND), Steward (DHCP) and Housecarl
|
||||
(nftables), and works like fail2ban, with one easy file instead of jails
|
||||
and filters spread over many:
|
||||
|
||||
```yaml
|
||||
housecarl:
|
||||
url: https://router.lan:8070
|
||||
token_file: /etc/guard/token
|
||||
list: web
|
||||
|
||||
watch:
|
||||
- name: ssh
|
||||
preset: sshd # OpenSSH, from the journal
|
||||
ban: {protocol: tcp, port: 22}
|
||||
- name: web-login
|
||||
file: /var/log/nginx/access.log
|
||||
match: ['^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 ']
|
||||
hits: 10
|
||||
ban: {time: 6h}
|
||||
```
|
||||
|
||||
* **One pattern placeholder, `<IP>`.** It matches IPv4 and IPv6
|
||||
(bracketed, IPv4-mapped); what only looks like an address is never
|
||||
taken for one.
|
||||
* **No date formats.** Guard follows logs as they are written, and
|
||||
counts each line at the time it reads it (the journal's entries have
|
||||
their own time).
|
||||
* **Files and the journal**, the journal by unit or syslog identifier
|
||||
(`sshd-session` on Debian 13).
|
||||
* **Presets** for OpenSSH, Postfix, Dovecot, nginx, BIND zone transfers,
|
||||
Jarl, Housecarl and Steward, and Housecarl's own log of dropped
|
||||
packets, for scans ([docs/presets.md](docs/presets.md)).
|
||||
* **Repeat offenders** get longer bans: each earlier ban doubles the
|
||||
time, up to a week.
|
||||
* **Never banned:** your own addresses, from Guard's `never_ban`, and
|
||||
from Housecarl, which never bans the inside networks, the firewall's
|
||||
own addresses or the machine asking.
|
||||
* **IPv6 by /64**, as the ban list says: one customer usually has a
|
||||
whole /64 and moves around in it.
|
||||
* **Housecarl away?** Bans wait and are sent once it answers.
|
||||
* **`guard test`** shows what a watch's patterns find in a log, and whom
|
||||
they would ban, before you trust it.
|
||||
|
||||
## Documentation
|
||||
|
||||
* [install.md](docs/install.md): installing, the ban list and token in Housecarl, first steps
|
||||
* [configuration.md](docs/configuration.md): `/etc/guard/guard.yaml`
|
||||
* [presets.md](docs/presets.md): the presets, and writing your own patterns
|
||||
* [cli.md](docs/cli.md): the `guard` command
|
||||
* [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
Housecarl's side: its [docs/bans.md](https://gitea.adbraun.lol/adam/housecarl/src/branch/main/docs/bans.md).
|
||||
|
||||
## Status
|
||||
|
||||
0.1.0 in development. Go, `CGO_ENABLED=0`, Linux. MIT licence.
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+26
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+10
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Guard's system user (systemd-sysusers): it reads logs and talks to
|
||||
# Housecarl, nothing more.
|
||||
u guard - "Guard log watcher" /var/lib/guard /usr/sbin/nologin
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# The guard command
|
||||
|
||||
One binary is the daemon and its tools. Every command reads
|
||||
`/etc/guard/guard.yaml` (`--config` for another); `--json` prints JSON.
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `guard run` | watch and ban: the daemon, as `guard.service` runs it |
|
||||
| `guard check` | check the configuration, compile the patterns, and ask Housecarl for each ban list (and its log, for `housecarl-drops`) |
|
||||
| `guard config` | the configuration as read, with the defaults filled in |
|
||||
| `guard config --example` | a commented example |
|
||||
| `guard presets [PRESET]` | the presets, or one's patterns |
|
||||
| `guard test WATCH [FILE\|-] [--lines N] [--all]` | what a watch's patterns find in a log, and whom they would ban ([presets.md](presets.md)) |
|
||||
| `guard status` | the running Guard: each watch's lines, matches, bans and highest bounties, the recent bans and why any failed, and bans waiting for Housecarl. Asks the socket: as root or the guard user |
|
||||
| `guard bans [--list L]` | the ban list's active bans, from Housecarl |
|
||||
| `guard ban ADDRESS [--list L] [--protocol tcp --port 22]... [--time 1d] [--reason R]` | ban by hand |
|
||||
| `guard unban ADDRESS [--list L]` | lift an address's bans |
|
||||
| `guard version` | |
|
||||
| `guard completion bash` | bash completion (the package installs it) |
|
||||
|
||||
`bans`, `ban` and `unban` use Guard's token, so they act on the ban
|
||||
lists it may ban into. `hc bans`, `hc ban` and `hc unban` on the
|
||||
firewall do the same for every list.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Configuration
|
||||
|
||||
`/etc/guard/guard.yaml` is Guard's whole configuration. `guard config
|
||||
--example` prints a commented one. Unknown settings are an error;
|
||||
`guard check` checks the file, compiles every pattern and asks Housecarl
|
||||
for the ban lists. Guard reads it when it starts:
|
||||
`systemctl restart guard` after a change.
|
||||
|
||||
## housecarl
|
||||
|
||||
| Setting | | |
|
||||
|---|---|---|
|
||||
| `url` | | Housecarl's API: `https://router.lan:8070` |
|
||||
| `socket` | | or its local socket, on the firewall: `/run/housecarl/housecarl.sock` |
|
||||
| `token_file` (or `token`) | | the API token: one for the ban lists Guard bans into (`bans:write:LIST`) |
|
||||
| `ca_cert` | | a PEM file with the CA of Housecarl's certificate, if private |
|
||||
| `insecure` | `false` | don't check Housecarl's certificate |
|
||||
| `list` | | the ban list a watch bans into unless it names another |
|
||||
|
||||
## never_ban
|
||||
|
||||
Addresses, prefixes and host names never banned, whatever the logs say:
|
||||
|
||||
```yaml
|
||||
never_ban: [198.51.100.7, 2001:db8:ffff::/48, vps.example.org]
|
||||
```
|
||||
|
||||
Host names are looked up when Guard starts and every ten minutes; if a
|
||||
lookup fails, the last addresses are kept. Loopback, link-local and
|
||||
multicast addresses are never banned either. Housecarl has its own list
|
||||
too: it never bans the inside networks, the firewall's own addresses,
|
||||
the machine asking, or a ban list's `never`.
|
||||
|
||||
## defaults
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| `hits` | 5 | an address is banned once it matches this many times… |
|
||||
| `within` | 10m | …within this long |
|
||||
| `ban` | 1h | how long a first ban lasts |
|
||||
| `repeat.factor` | 2 | each earlier ban of the address multiplies the time by this (1: bans don't grow) |
|
||||
| `repeat.max` | 1w | the longest ban |
|
||||
| `repeat.forget` | 1w | earlier bans older than this don't count |
|
||||
|
||||
Times are written `30s`, `10m`, `1h`, `1d`, `1w`, or combined (`1d12h`).
|
||||
Housecarl cuts a ban longer than its list's longest.
|
||||
|
||||
## watch
|
||||
|
||||
Each watch is a log, its patterns, and what a ban is:
|
||||
|
||||
```yaml
|
||||
watch:
|
||||
- name: ssh
|
||||
preset: sshd
|
||||
ban: {protocol: tcp, port: 22}
|
||||
- name: web-login
|
||||
file: /var/log/nginx/access.log
|
||||
match:
|
||||
- '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
|
||||
ignore: ['/healthz']
|
||||
hits: 10
|
||||
within: 5m
|
||||
ban: {list: web, time: 6h}
|
||||
```
|
||||
|
||||
| Setting | | |
|
||||
|---|---|---|
|
||||
| `name` | | the watch's name: letters, digits, `-`, `_`, `.` |
|
||||
| `preset` | | built-in patterns and their usual source ([presets.md](presets.md)) |
|
||||
| `file` | | a log file, or a glob (`/var/log/nginx/*access.log`) |
|
||||
| `journal` | | `{unit: ssh.service}` and/or `{identifier: sshd}`; each a name or a list. Entries of any of the units and of any of the identifiers |
|
||||
| `source` | | `housecarl-drops`: Housecarl's log of dropped packets |
|
||||
| `match` | | patterns (Go regular expressions) with one `<IP>` each; added to a preset's |
|
||||
| `ignore` | | lines matching any of these are skipped, even when they match |
|
||||
| `hits`, `within` | defaults | |
|
||||
| `ban.list` | `housecarl.list` | the ban list |
|
||||
| `ban.protocol`, `ban.port` | every port | `tcp` or `udp`, and a port or service name, or a list (`[http, https]`) |
|
||||
| `ban.time` | `defaults.ban` | a first ban's length |
|
||||
| `protocol` | `tcp` | `housecarl-drops` only: `tcp`, `udp` or `any`. UDP sources can be faked, so a scanner could get someone else banned |
|
||||
| `shadow` | `false` | `housecarl-drops` only: count what shadow mode would have dropped |
|
||||
| `disabled` | `false` | skip the watch |
|
||||
|
||||
A watch has one source: `file`, `journal` or `source`. A preset brings
|
||||
its own, which a `file` or `journal` given replaces.
|
||||
|
||||
### How lines are read
|
||||
|
||||
* **Files** are followed from their end when Guard starts: only new
|
||||
lines count. A file that appears later is read from its start. A
|
||||
rotated file (renamed, and a new one made) is read to its end, then
|
||||
the new one from its start; a truncated one (logrotate's
|
||||
`copytruncate`) is noticed while it is shorter than what was read, and
|
||||
read again from its start. Each line counts at the time Guard reads
|
||||
it: Guard parses no dates.
|
||||
* **The journal** is read through `journalctl --follow`, from now on,
|
||||
with each entry's own time. If journalctl ends, Guard starts it again
|
||||
after the last entry it saw. The guard user needs the
|
||||
`systemd-journal` group (the package's unit gives it).
|
||||
* **Housecarl's log** is read every two seconds (`GET /log`): each
|
||||
packet a logged rule (or a policy, with `log_policy_drops`) dropped or
|
||||
rejected counts once. The token needs `log:read`.
|
||||
|
||||
### Bounties and bans
|
||||
|
||||
Each match adds one to the address's bounty in that watch; matches older
|
||||
than `within` drop out. When the bounty reaches `hits`, Guard bans the
|
||||
address and its bounty starts again from nothing. IPv6 addresses are
|
||||
counted, and banned, by the prefix the ban list says (its `group_v6`,
|
||||
/64 by default), so an attacker can't step around a ban inside their
|
||||
/64.
|
||||
|
||||
A ban goes to Housecarl at once. If Housecarl can't be reached, it waits
|
||||
(in the state file, so a restart doesn't lose it) and is sent again
|
||||
every 30 seconds, for the time it has left. A ban Housecarl refuses (an
|
||||
address it never bans, a list the token doesn't cover) isn't sent again:
|
||||
`guard status` and the log say why.
|
||||
|
||||
## state, socket, log
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| `state` | `/var/lib/guard/state.json` | repeat offenders, recent bans, bans waiting for Housecarl |
|
||||
| `socket` | `/run/guard/guard.sock` | the local socket `guard status` asks; root and the guard user |
|
||||
| `log.level` | `info` | `debug` also logs every match with its bounty |
|
||||
| `log.format` | `text` | or `json` |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Installing Guard
|
||||
|
||||
Guard runs on any Linux machine whose logs it should watch: a web server
|
||||
behind the firewall, a mail server, the firewall itself. It needs
|
||||
Housecarl 0.2 or later (ban lists) on the firewall, reachable from the
|
||||
machine.
|
||||
|
||||
## 1. A ban list and a token, in Housecarl
|
||||
|
||||
On the firewall, make a ban list for this machine, and a user with a
|
||||
token that may ban into it and nothing else:
|
||||
|
||||
```sh
|
||||
hc banlist add web --scope hosts --host 10.0.10.57 --host 2001:db8:10::57
|
||||
hc user add guard-web --no-password --grant bans:write:web
|
||||
sudo hc token create web --user guard-web
|
||||
```
|
||||
|
||||
The scope says what a banned address is kept from:
|
||||
|
||||
* `hosts`: this machine (its addresses), the usual choice for a server
|
||||
behind the firewall. Port forwards to it are covered too.
|
||||
* `firewall`: the firewall itself, for Guard running on the firewall.
|
||||
* `everything`: the firewall and everything behind it.
|
||||
|
||||
A watch that reads Housecarl's own log (`housecarl-drops`) also needs
|
||||
`--grant log:read`.
|
||||
|
||||
## 2. The package
|
||||
|
||||
```sh
|
||||
sudo apt install ./guard_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
It makes the `guard` user (in the `adm` and `systemd-journal` groups,
|
||||
to read logs), installs `guard.service` without starting it, and writes
|
||||
an example `/etc/guard/guard.yaml`.
|
||||
|
||||
From source instead (Go 1.27): `make && sudo make install`, then
|
||||
`sudo systemd-sysusers guard.conf && sudo chgrp guard /etc/guard
|
||||
/etc/guard/guard.yaml && sudo systemctl daemon-reload`.
|
||||
|
||||
## 3. The configuration
|
||||
|
||||
Put the token in `/etc/guard/token`:
|
||||
|
||||
```sh
|
||||
sudo install -m 0640 -g guard /dev/stdin /etc/guard/token <<< 'housecarl_…'
|
||||
```
|
||||
|
||||
and edit `/etc/guard/guard.yaml`: Housecarl's URL, the ban list, and the
|
||||
watches ([configuration.md](configuration.md)). Then:
|
||||
|
||||
```sh
|
||||
sudo -u guard guard check # the file, the patterns, Housecarl
|
||||
sudo -u guard guard test ssh # what the ssh watch finds in the journal
|
||||
sudo systemctl enable --now guard
|
||||
guard status # as root
|
||||
```
|
||||
|
||||
On the firewall itself, use Housecarl's local socket instead of the URL
|
||||
(`housecarl.socket: /run/housecarl/housecarl.sock`), and give the guard
|
||||
user access to it: `SupplementaryGroups=adm systemd-journal housecarl`
|
||||
in `systemctl edit guard`. A token is still needed: Housecarl trusts
|
||||
only root and its own user on the socket without one.
|
||||
|
||||
## fail2ban instead
|
||||
|
||||
If you already run fail2ban, Housecarl ships an action for it that bans
|
||||
into a ban list the same way (Housecarl's docs/bans.md). Guard and
|
||||
fail2ban can also run side by side, on different machines or watching
|
||||
different logs.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Presets and patterns
|
||||
|
||||
A preset is a watch Guard knows: its patterns, and where its program's
|
||||
lines usually are. `guard presets` lists them; `guard presets sshd`
|
||||
shows one's patterns.
|
||||
|
||||
```yaml
|
||||
watch:
|
||||
- name: ssh
|
||||
preset: sshd
|
||||
ban: {protocol: tcp, port: 22}
|
||||
- name: ssh-file # the same, from a syslog file
|
||||
preset: sshd
|
||||
file: /var/log/auth.log
|
||||
```
|
||||
|
||||
A watch's own `file` or `journal` replaces the preset's source, and its
|
||||
`match` and `ignore` lines are added to the preset's.
|
||||
|
||||
| Preset | Source | Matches |
|
||||
|---|---|---|
|
||||
| `sshd` | journal, identifiers `sshd`, `sshd-session` | failed passwords and keys, unknown users, connections closed before login, too many attempts, failed key exchanges, garbage banners |
|
||||
| `jarl`, `housecarl`, `steward` | journal, units `jarl.service`, `housecarl.service`, `steward.service` | their failed logins and refused API tokens (`msg="login failed" ip=…`, text or JSON logs) |
|
||||
| `bind-xfr` | journal, identifier `named` | zone transfers BIND refused (TCP, so the address is real) |
|
||||
| `postfix-sasl` | journal, identifiers `postfix/smtpd`, `postfix/submission/smtpd`, `postfix/smtps/smtpd` | failed SMTP logins |
|
||||
| `dovecot` | journal, identifier `dovecot` | failed IMAP, POP3, submission and ManageSieve logins |
|
||||
| `nginx-auth` | file `/var/log/nginx/error.log` | failed HTTP basic authentication |
|
||||
| `housecarl-drops` | Housecarl's log of dropped packets | every TCP packet a logged rule dropped (on the firewall, for scans) |
|
||||
|
||||
OpenSSH 9.8 and later (Debian 13) logs logins from `sshd-session`, not
|
||||
`sshd`; the preset reads both. Debian 13 has no `/var/log/auth.log`
|
||||
unless rsyslog is installed: the journal is the place.
|
||||
|
||||
There's deliberately no preset for BIND's refused queries: they come
|
||||
over UDP, whose source address anyone can fake, so banning on them would
|
||||
let a stranger get your users' resolvers banned. Use BIND's response
|
||||
rate limiting for floods.
|
||||
|
||||
## Writing patterns
|
||||
|
||||
A pattern is a Go regular expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax))
|
||||
with `<IP>` once, where the address is:
|
||||
|
||||
```yaml
|
||||
match:
|
||||
- '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
|
||||
- 'authentication failure; .* rhost=<IP>(?: |$)'
|
||||
```
|
||||
|
||||
`<IP>` matches IPv4 and IPv6 addresses, bracketed (`[2001:db8::9]`),
|
||||
IPv4-mapped (`::ffff:203.0.113.9`, counted as `203.0.113.9`) or with a
|
||||
zone. The text it matches must be a real address: `12:34:56` isn't one.
|
||||
|
||||
Patterns match anywhere in a line unless anchored. A line from a syslog
|
||||
file starts with its date, host and program (`Oct 22 10:00:01 web
|
||||
sshd[123]: `); a journal entry is the message alone. The presets begin
|
||||
with `(?:^|: )` so they work on both.
|
||||
|
||||
**Where the address is matters.** Many log lines carry text a client
|
||||
chose: a user name, a URL, a reverse DNS name. If a pattern lets that
|
||||
text supply the address, a client can get any address banned:
|
||||
|
||||
```
|
||||
Failed password for invalid user x from 198.51.100.1 port 22 from 203.0.113.9 port 4444 ssh2
|
||||
```
|
||||
|
||||
Anchor patterns at the end of the line, or put `<IP>` where the
|
||||
program writes the address after the client's text, so it is the
|
||||
program's address that matches. The presets do; `guard test` shows
|
||||
what yours find. Programs that log the address first (Jarl, Housecarl,
|
||||
Steward log `msg="login failed" ip=… user=…`) are the easiest to match
|
||||
safely.
|
||||
|
||||
## Trying patterns: guard test
|
||||
|
||||
```sh
|
||||
guard test web-login /var/log/nginx/access.log.1
|
||||
guard test ssh # a journal watch: its last 5000 entries
|
||||
zcat /var/log/nginx/access.log.2.gz | guard test web-login -
|
||||
```
|
||||
|
||||
It prints each matching line with its address, then the addresses that
|
||||
would be banned. Nothing is banned. Lines from a file carry no time
|
||||
Guard reads, so every match counts however far apart; the journal's
|
||||
entries have times, and the watch's window is used.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// Package config reads Guard's configuration: one YAML file,
|
||||
// /etc/guard/guard.yaml (docs/configuration.md). Unknown settings are an
|
||||
// error; everything not given has a default.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"guard/internal/match"
|
||||
)
|
||||
|
||||
// DefaultPath is where the configuration is read from.
|
||||
const DefaultPath = "/etc/guard/guard.yaml"
|
||||
|
||||
// Config is guard.yaml.
|
||||
type Config struct {
|
||||
Housecarl Housecarl `yaml:"housecarl"`
|
||||
// NeverBan are addresses, prefixes and host names never banned.
|
||||
NeverBan []string `yaml:"never_ban"`
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
Watch []*Watch `yaml:"watch"`
|
||||
// State is where Guard keeps what must outlive a restart: repeat
|
||||
// offenders, recent bans, bans waiting for Housecarl.
|
||||
State string `yaml:"state"`
|
||||
// Socket is the local socket 'guard status' asks.
|
||||
Socket string `yaml:"socket"`
|
||||
Log Log `yaml:"log"`
|
||||
}
|
||||
|
||||
// Housecarl is how Guard reaches Housecarl.
|
||||
type Housecarl struct {
|
||||
// URL is Housecarl's API ("https://router.lan:8070"), or Socket its
|
||||
// local socket on the firewall itself.
|
||||
URL string `yaml:"url"`
|
||||
Socket string `yaml:"socket"`
|
||||
// TokenFile (or Token) is the API token: one for the ban lists Guard
|
||||
// bans into (bans:write:LIST).
|
||||
TokenFile string `yaml:"token_file"`
|
||||
Token string `yaml:"token"`
|
||||
// CACert trusts a private CA for Housecarl's certificate; Insecure
|
||||
// skips checking it.
|
||||
CACert string `yaml:"ca_cert"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
// List is the ban list a watch bans into unless it names another.
|
||||
List string `yaml:"list"`
|
||||
}
|
||||
|
||||
// Defaults are the watches' settings unless they say otherwise.
|
||||
type Defaults struct {
|
||||
// Hits matches of an address within Within ban it.
|
||||
Hits int `yaml:"hits"`
|
||||
Within Duration `yaml:"within"`
|
||||
// Ban is how long a first ban lasts.
|
||||
Ban Duration `yaml:"ban"`
|
||||
Repeat Repeat `yaml:"repeat"`
|
||||
}
|
||||
|
||||
// Repeat makes repeat offenders' bans longer: each earlier ban within
|
||||
// Forget multiplies the time by Factor, up to Max.
|
||||
type Repeat struct {
|
||||
Factor float64 `yaml:"factor"`
|
||||
Max Duration `yaml:"max"`
|
||||
Forget Duration `yaml:"forget"`
|
||||
}
|
||||
|
||||
// Log is Guard's own logging.
|
||||
type Log struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
// Watch is one log to watch: where its lines come from, what in them is
|
||||
// a misdeed, and what a ban is.
|
||||
type Watch struct {
|
||||
Name string `yaml:"name"`
|
||||
// Preset names built-in patterns and their usual source
|
||||
// (docs/presets.md); the watch's own match lines are added.
|
||||
Preset string `yaml:"preset"`
|
||||
// One source: a file (or glob), the journal, or Housecarl's log of
|
||||
// dropped packets (source: housecarl-drops).
|
||||
File string `yaml:"file"`
|
||||
Journal *Journal `yaml:"journal"`
|
||||
Source string `yaml:"source"`
|
||||
// Match are regular expressions with one <IP>; Ignore lines are
|
||||
// skipped even when they match.
|
||||
Match []string `yaml:"match"`
|
||||
Ignore []string `yaml:"ignore"`
|
||||
// Protocol limits housecarl-drops to a protocol (default tcp: UDP
|
||||
// sources can be faked); Shadow counts shadow mode's would-be drops.
|
||||
Protocol string `yaml:"protocol"`
|
||||
Shadow bool `yaml:"shadow"`
|
||||
// Hits and Within override the defaults.
|
||||
Hits int `yaml:"hits"`
|
||||
Within Duration `yaml:"within"`
|
||||
Ban Ban `yaml:"ban"`
|
||||
// Disabled watches are skipped.
|
||||
Disabled bool `yaml:"disabled"`
|
||||
}
|
||||
|
||||
// Journal selects journal entries: by systemd unit and/or syslog
|
||||
// identifier (each a name or a list; entries of any).
|
||||
type Journal struct {
|
||||
Unit Strings `yaml:"unit"`
|
||||
Identifier Strings `yaml:"identifier"`
|
||||
}
|
||||
|
||||
// Ban is what a watch's ban is: into which list, of which ports, for how
|
||||
// long.
|
||||
type Ban struct {
|
||||
List string `yaml:"list"`
|
||||
// Protocol and Port limit the ban (port numbers or service names);
|
||||
// without them, every port.
|
||||
Protocol string `yaml:"protocol"`
|
||||
Port Strings `yaml:"port"`
|
||||
Time Duration `yaml:"time"`
|
||||
}
|
||||
|
||||
// Strings is a YAML scalar or list of scalars.
|
||||
type Strings []string
|
||||
|
||||
// UnmarshalYAML reads "x", 22, or [x, 22].
|
||||
func (s *Strings) UnmarshalYAML(n *yaml.Node) error {
|
||||
switch n.Kind {
|
||||
case yaml.ScalarNode:
|
||||
*s = Strings{n.Value}
|
||||
return nil
|
||||
case yaml.SequenceNode:
|
||||
var out []string
|
||||
for _, c := range n.Content {
|
||||
if c.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("line %d: give a name, or a list of names", c.Line)
|
||||
}
|
||||
out = append(out, c.Value)
|
||||
}
|
||||
*s = out
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("line %d: give a name, or a list of names", n.Line)
|
||||
}
|
||||
|
||||
// Duration is a length of time: 30s, 10m, 1h, 1d, 1w, or combined
|
||||
// (1d12h).
|
||||
type Duration time.Duration
|
||||
|
||||
var durRe = regexp.MustCompile(`^(\d+[wdhms])+$`)
|
||||
|
||||
// ParseDuration reads a Duration.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !durRe.MatchString(s) {
|
||||
return 0, fmt.Errorf("%q isn't a length of time: write it as 30s, 10m, 1h, 1d or 1w", s)
|
||||
}
|
||||
var d time.Duration
|
||||
unit := map[byte]time.Duration{'w': 7 * 24 * time.Hour, 'd': 24 * time.Hour, 'h': time.Hour, 'm': time.Minute, 's': time.Second}
|
||||
for _, m := range regexp.MustCompile(`(\d+)([wdhms])`).FindAllStringSubmatch(s, -1) {
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d += time.Duration(n) * unit[m[2][0]]
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML reads a Duration.
|
||||
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
|
||||
v, err := ParseDuration(n.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("line %d: %v", n.Line, err)
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// D is the time.Duration.
|
||||
func (d Duration) D() time.Duration { return time.Duration(d) }
|
||||
|
||||
// String writes a Duration the short way: 1h30m, 1w.
|
||||
func (d Duration) String() string { return FormatDuration(time.Duration(d)) }
|
||||
|
||||
// FormatDuration writes a length of time the short way.
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "0s"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, u := range []struct {
|
||||
d time.Duration
|
||||
s string
|
||||
}{{7 * 24 * time.Hour, "w"}, {24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
|
||||
if d >= u.d {
|
||||
fmt.Fprintf(&b, "%d%s", d/u.d, u.s)
|
||||
d %= u.d
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Load reads and checks the configuration.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Parse(data)
|
||||
}
|
||||
|
||||
// Parse reads and checks a configuration.
|
||||
func Parse(data []byte) (*Config, error) {
|
||||
c := &Config{}
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("guard.yaml: %w", err)
|
||||
}
|
||||
c.defaults()
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Config) defaults() {
|
||||
d := &c.Defaults
|
||||
if d.Hits == 0 {
|
||||
d.Hits = 5
|
||||
}
|
||||
if d.Within == 0 {
|
||||
d.Within = Duration(10 * time.Minute)
|
||||
}
|
||||
if d.Ban == 0 {
|
||||
d.Ban = Duration(time.Hour)
|
||||
}
|
||||
if d.Repeat.Factor == 0 {
|
||||
d.Repeat.Factor = 2
|
||||
}
|
||||
if d.Repeat.Max == 0 {
|
||||
d.Repeat.Max = Duration(7 * 24 * time.Hour)
|
||||
}
|
||||
if d.Repeat.Forget == 0 {
|
||||
d.Repeat.Forget = Duration(7 * 24 * time.Hour)
|
||||
}
|
||||
if c.State == "" {
|
||||
c.State = "/var/lib/guard/state.json"
|
||||
}
|
||||
if c.Socket == "" {
|
||||
c.Socket = "/run/guard/guard.sock"
|
||||
}
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = "info"
|
||||
}
|
||||
if c.Log.Format == "" {
|
||||
c.Log.Format = "text"
|
||||
}
|
||||
for _, w := range c.Watch {
|
||||
if w.Hits == 0 {
|
||||
w.Hits = d.Hits
|
||||
}
|
||||
if w.Within == 0 {
|
||||
w.Within = d.Within
|
||||
}
|
||||
if w.Ban.Time == 0 {
|
||||
w.Ban.Time = d.Ban
|
||||
}
|
||||
if w.Ban.List == "" {
|
||||
w.Ban.List = c.Housecarl.List
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,47}$`)
|
||||
|
||||
// Source names besides files and the journal.
|
||||
const SourceHousecarlDrops = "housecarl-drops"
|
||||
|
||||
func (c *Config) check() error {
|
||||
var errs []string
|
||||
add := func(format string, args ...any) { errs = append(errs, fmt.Sprintf(format, args...)) }
|
||||
h := c.Housecarl
|
||||
switch {
|
||||
case h.URL == "" && h.Socket == "":
|
||||
add("housecarl: give url (https://router.lan:8070) or socket (/run/housecarl/housecarl.sock)")
|
||||
case h.URL != "" && h.Socket != "":
|
||||
add("housecarl: give url or socket, not both")
|
||||
case h.URL != "" && !strings.HasPrefix(h.URL, "http://") && !strings.HasPrefix(h.URL, "https://"):
|
||||
add("housecarl.url %q: start it with https:// (or http://)", h.URL)
|
||||
}
|
||||
if h.Token != "" && h.TokenFile != "" {
|
||||
add("housecarl: give token_file or token, not both")
|
||||
}
|
||||
for _, n := range c.NeverBan {
|
||||
if _, err := netip.ParsePrefix(n); err == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := netip.ParseAddr(n); err == nil {
|
||||
continue
|
||||
}
|
||||
if !hostRe.MatchString(n) {
|
||||
add("never_ban: %q isn't an address, prefix or host name", n)
|
||||
}
|
||||
}
|
||||
d := c.Defaults
|
||||
if d.Hits < 1 {
|
||||
add("defaults.hits is %d; use 1 or more", d.Hits)
|
||||
}
|
||||
if d.Repeat.Factor < 1 {
|
||||
add("defaults.repeat.factor is %g; use 1 (bans don't grow) or more", d.Repeat.Factor)
|
||||
}
|
||||
if lv := strings.ToLower(c.Log.Level); lv != "debug" && lv != "info" && lv != "warn" && lv != "error" {
|
||||
add("log.level %q: use debug, info, warn or error", c.Log.Level)
|
||||
}
|
||||
if c.Log.Format != "text" && c.Log.Format != "json" {
|
||||
add("log.format %q: use text or json", c.Log.Format)
|
||||
}
|
||||
if len(c.Watch) == 0 {
|
||||
add("watch: nothing to watch; add a watch (guard config --example shows some)")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, w := range c.Watch {
|
||||
what := fmt.Sprintf("watch %d", i+1)
|
||||
if w.Name != "" {
|
||||
what = "watch " + w.Name
|
||||
}
|
||||
if !nameRe.MatchString(w.Name) {
|
||||
add("%s: give it a name (letters, digits, - _ .; starting with a letter)", what)
|
||||
} else if seen[strings.ToLower(w.Name)] {
|
||||
add("%s: the name is used twice", what)
|
||||
}
|
||||
seen[strings.ToLower(w.Name)] = true
|
||||
if err := w.resolve(); err != nil {
|
||||
add("%s: %v", what, err)
|
||||
continue
|
||||
}
|
||||
if w.Hits < 1 {
|
||||
add("%s: hits is %d; use 1 or more", what, w.Hits)
|
||||
}
|
||||
if w.Ban.List == "" {
|
||||
add("%s: which ban list? Set housecarl.list, or ban.list", what)
|
||||
}
|
||||
switch strings.ToLower(w.Ban.Protocol) {
|
||||
case "", "tcp", "udp":
|
||||
default:
|
||||
add("%s: ban.protocol %q: use tcp, udp, or leave it out", what, w.Ban.Protocol)
|
||||
}
|
||||
if w.Ban.Protocol != "" && len(w.Ban.Port) == 0 {
|
||||
add("%s: ban.protocol without ban.port: give the port, or leave both out to ban every port", what)
|
||||
}
|
||||
if _, err := match.Compile(w.Match, w.Ignore); err != nil {
|
||||
add("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("guard.yaml:\n %s", strings.Join(errs, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`)
|
||||
|
||||
// Kind is a watch's kind of source: "file", "journal" or
|
||||
// "housecarl-drops" (after resolve).
|
||||
func (w *Watch) Kind() string {
|
||||
switch {
|
||||
case w.Source != "":
|
||||
return w.Source
|
||||
case w.Journal != nil:
|
||||
return "journal"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
// resolve fills in a watch's preset: its source unless the watch gives
|
||||
// one, its patterns before the watch's own.
|
||||
func (w *Watch) resolve() error {
|
||||
n := 0
|
||||
for _, set := range []bool{w.File != "", w.Journal != nil, w.Source != ""} {
|
||||
if set {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 1 {
|
||||
return fmt.Errorf("give one source: file, journal or source")
|
||||
}
|
||||
if w.Source != "" && w.Source != SourceHousecarlDrops {
|
||||
return fmt.Errorf("source %q: the only one besides file and journal is %s", w.Source, SourceHousecarlDrops)
|
||||
}
|
||||
if w.Preset != "" {
|
||||
p, ok := Presets()[w.Preset]
|
||||
if !ok {
|
||||
return fmt.Errorf("there is no preset %q (guard presets lists them)", w.Preset)
|
||||
}
|
||||
if n == 0 {
|
||||
switch {
|
||||
case p.Source != "":
|
||||
w.Source = p.Source
|
||||
case p.Journal != nil:
|
||||
j := *p.Journal
|
||||
w.Journal = &j
|
||||
default:
|
||||
w.File = p.File
|
||||
}
|
||||
}
|
||||
w.Match = append(append([]string{}, p.Match...), w.Match...)
|
||||
w.Ignore = append(append([]string{}, p.Ignore...), w.Ignore...)
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("give a source: file, journal, source, or a preset")
|
||||
}
|
||||
if w.Journal != nil && len(w.Journal.Unit) == 0 && len(w.Journal.Identifier) == 0 {
|
||||
return fmt.Errorf("journal: give a unit or an identifier (or both)")
|
||||
}
|
||||
if w.Source == SourceHousecarlDrops {
|
||||
if len(w.Match) > 0 {
|
||||
return fmt.Errorf("housecarl-drops has no patterns: every dropped packet counts")
|
||||
}
|
||||
switch strings.ToLower(w.Protocol) {
|
||||
case "":
|
||||
w.Protocol = "tcp"
|
||||
case "tcp", "udp", "any":
|
||||
default:
|
||||
return fmt.Errorf("protocol %q: use tcp, udp or any", w.Protocol)
|
||||
}
|
||||
} else {
|
||||
if w.Protocol != "" || w.Shadow {
|
||||
return fmt.Errorf("protocol and shadow are for source housecarl-drops")
|
||||
}
|
||||
if len(w.Match) == 0 {
|
||||
return fmt.Errorf("no match patterns: give some, or a preset")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"guard/internal/match"
|
||||
)
|
||||
|
||||
// TestPresets runs each preset over its sample lines in testdata/presets:
|
||||
// what it must find, and what it must leave alone.
|
||||
func TestPresets(t *testing.T) {
|
||||
for name, p := range Presets() {
|
||||
if p.Source != "" {
|
||||
continue
|
||||
}
|
||||
m, err := match.Compile(p.Match, p.Ignore)
|
||||
if err != nil {
|
||||
t.Fatalf("preset %s: %v", name, err)
|
||||
}
|
||||
f, err := os.Open(filepath.Join("..", "..", "testdata", "presets", name+".txt"))
|
||||
if err != nil {
|
||||
t.Errorf("preset %s has no sample lines: %v", name, err)
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
want, line, ok := strings.Cut(sc.Text(), "\t")
|
||||
if !ok || strings.HasPrefix(want, "#") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
a, matched := m.Match(line)
|
||||
switch {
|
||||
case want == "-" && matched:
|
||||
t.Errorf("%s: matched %s in %q", name, a, line)
|
||||
case want != "-" && !matched:
|
||||
t.Errorf("%s: no match in %q", name, line)
|
||||
case want != "-" && a.String() != want:
|
||||
t.Errorf("%s: %s, want %s, in %q", name, a, want, line)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
if n == 0 {
|
||||
t.Errorf("preset %s: no sample lines", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleParses(t *testing.T) {
|
||||
c, err := Parse([]byte(Example()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := c.Watch[0]
|
||||
if w.Kind() != "journal" || w.Ban.List != "web" || w.Hits != 5 || w.Within.D() != 10*time.Minute || len(w.Match) == 0 {
|
||||
t.Errorf("the ssh watch: %+v", w)
|
||||
}
|
||||
if c.Defaults.Repeat.Max.D() != 7*24*time.Hour {
|
||||
t.Errorf("repeat %+v", c.Defaults.Repeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
base := "housecarl: {url: https://fw:8070, list: web}\n"
|
||||
cases := []struct{ yaml, want string }{
|
||||
{"", "give url"},
|
||||
{base + "watch: [{name: a, preset: nope}]", `no preset "nope"`},
|
||||
{base + "watch: [{name: a, file: /x, match: ['no address']}]", "exactly one"},
|
||||
{base + "watch: [{name: a, file: /x, journal: {unit: x}, match: ['<IP>']}]", "one source"},
|
||||
{base + "watch: [{name: a, file: /x}]", "no match patterns"},
|
||||
{base + "watch: [{name: a, source: housecarl-drops, match: ['<IP>']}]", "no patterns"},
|
||||
{base + "watch: [{name: a, preset: sshd, ban: {protocol: tcp}}]", "without ban.port"},
|
||||
{base + "watch: [{name: a, preset: sshd, within: 10 minutes}]", "isn't a length of time"},
|
||||
{base + "watch: [{name: a, preset: sshd}, {name: A, preset: sshd}]", "used twice"},
|
||||
{base + "never_ban: ['not an address!']\nwatch: [{name: a, preset: sshd}]", "never_ban"},
|
||||
{base + "bogus: 1\nwatch: [{name: a, preset: sshd}]", "bogus"},
|
||||
{"housecarl: {url: https://fw:8070}\nwatch: [{name: a, preset: sshd}]", "which ban list"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := Parse([]byte(c.yaml))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%q: %v, want %q", c.yaml, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetSourceAndOwnPatterns(t *testing.T) {
|
||||
c, err := Parse([]byte(`housecarl: {socket: /run/housecarl/housecarl.sock, list: router}
|
||||
watch:
|
||||
- {name: ssh, preset: sshd, file: /var/log/auth.log, match: ['custom <IP> line']}
|
||||
- {name: scans, preset: housecarl-drops}
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ssh := c.Watch[0]
|
||||
if ssh.Kind() != "file" || ssh.File != "/var/log/auth.log" || ssh.Match[len(ssh.Match)-1] != "custom <IP> line" || len(ssh.Match) < 2 {
|
||||
t.Errorf("own file and pattern beside the preset's: %+v", ssh)
|
||||
}
|
||||
if s := c.Watch[1]; s.Kind() != SourceHousecarlDrops || s.Protocol != "tcp" {
|
||||
t.Errorf("housecarl-drops: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuration(t *testing.T) {
|
||||
for in, want := range map[string]time.Duration{"30s": 30 * time.Second, "1d12h": 36 * time.Hour, "1w": 7 * 24 * time.Hour} {
|
||||
if d, err := ParseDuration(in); err != nil || d != want {
|
||||
t.Errorf("%s: %v %v", in, d, err)
|
||||
}
|
||||
if got := FormatDuration(want); got != in {
|
||||
t.Errorf("format %v: %s, want %s", want, got, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Guard's configuration (docs/configuration.md). Guard reads it when it
|
||||
# starts: systemctl restart guard after a change, and 'guard check' to
|
||||
# check it first.
|
||||
|
||||
# Housecarl, which does the banning. Give Guard a token for its ban list
|
||||
# alone; on the firewall:
|
||||
# hc banlist add web --scope hosts --host 10.0.10.57
|
||||
# hc user add guard-web --no-password --grant bans:write:web
|
||||
# hc token create web --user guard-web # as root
|
||||
housecarl:
|
||||
url: https://router.lan:8070
|
||||
# On the firewall itself, the local socket instead of url:
|
||||
# socket: /run/housecarl/housecarl.sock
|
||||
token_file: /etc/guard/token
|
||||
# ca_cert: /etc/guard/housecarl-ca.pem # a private CA
|
||||
# The ban list a watch bans into, unless it says another.
|
||||
list: web
|
||||
|
||||
# Never banned, whatever the logs say: addresses, prefixes, host names
|
||||
# (looked up every ten minutes). Housecarl itself never bans the inside
|
||||
# networks, the firewall's own addresses, or this machine.
|
||||
never_ban:
|
||||
# - 198.51.100.7
|
||||
# - 2001:db8:ffff::/48
|
||||
# - vps.example.org
|
||||
|
||||
# What every watch does unless it says otherwise: this many matches of an
|
||||
# address within this time ban it for this long. Each earlier ban of the
|
||||
# address within "forget" doubles the time ("factor"), up to "max".
|
||||
defaults:
|
||||
hits: 5
|
||||
within: 10m
|
||||
ban: 1h
|
||||
repeat: {factor: 2, max: 1w, forget: 1w}
|
||||
|
||||
watch:
|
||||
# OpenSSH, from the journal (the preset knows where).
|
||||
- name: ssh
|
||||
preset: sshd
|
||||
ban: {protocol: tcp, port: 22}
|
||||
|
||||
# A log file, with your own pattern: <IP> is where the address is.
|
||||
# - name: web-login
|
||||
# file: /var/log/nginx/access.log
|
||||
# match:
|
||||
# - '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
|
||||
# ignore:
|
||||
# - '/healthz'
|
||||
# hits: 10
|
||||
# ban: {time: 6h}
|
||||
|
||||
# Jarl's, Housecarl's or Steward's failed logins (presets jarl,
|
||||
# housecarl, steward), banned from every port of the list's scope.
|
||||
# - name: jarl
|
||||
# preset: jarl
|
||||
|
||||
# On the firewall: sources whose packets Housecarl's logged rules keep
|
||||
# dropping (TCP only: UDP sources can be faked). The token also needs
|
||||
# log:read.
|
||||
# - name: scans
|
||||
# preset: housecarl-drops
|
||||
# hits: 20
|
||||
# within: 1m
|
||||
# ban: {list: edge, time: 1d}
|
||||
|
||||
# Guard's memory of its bans (repeat offenders), and the socket 'guard
|
||||
# status' asks.
|
||||
state: /var/lib/guard/state.json
|
||||
socket: /run/guard/guard.sock
|
||||
|
||||
log: {level: info, format: text}
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,70 @@
|
||||
# Guard's presets (docs/presets.md): patterns for common programs, and
|
||||
# where their lines usually are. A watch's own file or journal replaces
|
||||
# the preset's source; its match and ignore lines are added to these.
|
||||
#
|
||||
# Patterns are anchored where the program's format allows, and put the
|
||||
# address where the program writes it, after anything a client chooses
|
||||
# (a user name), so a chosen name can't plant another address. Lines
|
||||
# from a syslog file carry a "host prog[pid]: " head that journal
|
||||
# messages don't, hence "(?:^|: )" before a message's first word.
|
||||
|
||||
sshd:
|
||||
description: OpenSSH's failed logins, unknown users and broken handshakes
|
||||
# OpenSSH 9.8 and later (Debian 13) logs from sshd-session.
|
||||
journal: {identifier: [sshd, sshd-session]}
|
||||
match:
|
||||
- '(?:^|: )Failed (?:password|publickey|keyboard-interactive/pam|none) for .* from <IP> port \d+(?: ssh2)?$'
|
||||
- '(?:^|: )Invalid user .* from <IP> port \d+$'
|
||||
- '(?:^|: )(?:Connection (?:closed|reset)|Disconnected) (?:by|from) (?:invalid|authenticating) user .* <IP> port \d+ \[preauth\]$'
|
||||
- '(?:^|: )error: maximum authentication attempts exceeded for .* from <IP> port \d+ ssh2 \[preauth\]$'
|
||||
- '(?:^|: )Unable to negotiate with <IP> port \d+: no matching (?:key exchange method|host key type|cipher|MAC) found'
|
||||
- '(?:^|: )banner exchange: Connection from <IP> port \d+: invalid format$'
|
||||
|
||||
jarl:
|
||||
description: failed logins and refused API tokens of Jarl (the BIND manager)
|
||||
journal: {unit: jarl.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
housecarl:
|
||||
description: failed logins and refused API tokens of Housecarl (the firewall manager)
|
||||
journal: {unit: housecarl.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
steward:
|
||||
description: failed logins and refused API tokens of Steward (the DHCP server)
|
||||
journal: {unit: steward.service}
|
||||
match:
|
||||
- 'msg="(?:login failed|token refused)" ip=<IP> '
|
||||
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
|
||||
|
||||
bind-xfr:
|
||||
description: zone transfers BIND refused (over TCP, so the address is real)
|
||||
journal: {identifier: named}
|
||||
match:
|
||||
- 'client @0x[0-9a-f]+ <IP>#\d+ .*: zone transfer .* denied$'
|
||||
|
||||
postfix-sasl:
|
||||
description: Postfix's failed SMTP logins (SASL)
|
||||
journal: {identifier: [postfix/smtpd, postfix/submission/smtpd, postfix/smtps/smtpd]}
|
||||
match:
|
||||
- 'warning: [-._0-9A-Za-z]+\[<IP>\]: SASL [-A-Z0-9]+ authentication failed'
|
||||
|
||||
dovecot:
|
||||
description: Dovecot's failed IMAP and POP3 logins
|
||||
journal: {identifier: dovecot}
|
||||
match:
|
||||
- '(?:imap|pop3|submission|managesieve)-login: (?:Disconnected|Aborted login).*\(auth failed, \d+ attempts? in \d+ secs\): user=<.*>, .*rip=<IP>, lip='
|
||||
|
||||
nginx-auth:
|
||||
description: nginx's failed HTTP basic authentication (error.log)
|
||||
file: /var/log/nginx/error.log
|
||||
match:
|
||||
- '\buser "[^"]*"(?: was not found in "[^"]*"|: password mismatch), client: <IP>, server: '
|
||||
|
||||
housecarl-drops:
|
||||
description: packets Housecarl's rules dropped and logged (on the firewall; TCP only by default)
|
||||
source: housecarl-drops
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package match finds the address in a log line: patterns are regular
|
||||
// expressions (Go's, RE2) with one <IP>, which matches an IPv4 or IPv6
|
||||
// address (bracketed, IPv4-mapped, with a zone). The address is checked
|
||||
// with netip, so what only looks like one (a time, 12:34:56) never is.
|
||||
package match
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Placeholder is where a pattern's address is.
|
||||
const Placeholder = "<IP>"
|
||||
|
||||
// ipRe matches an address: IPv4, or IPv6 (which may end in IPv4,
|
||||
// ::ffff:203.0.113.9), bracketed or not, with an optional zone.
|
||||
const ipRe = `\[?(?P<ip>[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){1,7}(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3})?(?:%[0-9A-Za-z_.-]+)?)\]?`
|
||||
|
||||
// Matcher holds a watch's patterns.
|
||||
type Matcher struct {
|
||||
match []*regexp.Regexp
|
||||
ignore []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Compile makes a Matcher. Each match pattern must hold <IP> exactly
|
||||
// once; ignore patterns are plain regular expressions.
|
||||
func Compile(match, ignore []string) (*Matcher, error) {
|
||||
m := &Matcher{}
|
||||
for _, p := range match {
|
||||
if n := strings.Count(p, Placeholder); n != 1 {
|
||||
return nil, fmt.Errorf("pattern %q has %d %s; it needs exactly one, where the address is", p, n, Placeholder)
|
||||
}
|
||||
re, err := regexp.Compile(strings.Replace(p, Placeholder, ipRe, 1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pattern %q: %v", p, err)
|
||||
}
|
||||
m.match = append(m.match, re)
|
||||
}
|
||||
for _, p := range ignore {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ignore pattern %q: %v", p, err)
|
||||
}
|
||||
m.ignore = append(m.ignore, re)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Match returns the address of the first pattern a line matches, unless
|
||||
// an ignore pattern matches it too.
|
||||
func (m *Matcher) Match(line string) (netip.Addr, bool) {
|
||||
for _, re := range m.match {
|
||||
sub := re.FindStringSubmatch(line)
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
text := sub[re.SubexpIndex("ip")]
|
||||
if i := strings.IndexByte(text, '%'); i >= 0 {
|
||||
text = text[:i]
|
||||
}
|
||||
a, err := netip.ParseAddr(text)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ig := range m.ignore {
|
||||
if ig.MatchString(line) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
}
|
||||
return a.Unmap(), true
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Group is the prefix an address is counted and banned by: an IPv4
|
||||
// address alone, an IPv6 address by its /v6 (64: what one customer
|
||||
// usually has).
|
||||
func Group(a netip.Addr, v6 int) netip.Prefix {
|
||||
if a.Is4() || v6 <= 0 || v6 > 128 {
|
||||
if a.Is4() {
|
||||
return netip.PrefixFrom(a, 32)
|
||||
}
|
||||
v6 = 128
|
||||
}
|
||||
p, _ := a.Prefix(v6)
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
m, err := Compile([]string{`from <IP> port`, `client: <IP>,`, `^conn <IP>$`}, []string{`trusted-scanner`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"from 203.0.113.9 port 22": "203.0.113.9",
|
||||
"from 2001:db8::9 port 22": "2001:db8::9",
|
||||
"from ::ffff:203.0.113.9 port 22": "203.0.113.9",
|
||||
"from [2001:db8::9] port 22": "2001:db8::9",
|
||||
"from fe80::1%eth0 port 22": "fe80::1",
|
||||
"client: 203.0.113.9, server": "203.0.113.9",
|
||||
"conn 2001:db8:1:2:3:4:5:6": "2001:db8:1:2:3:4:5:6",
|
||||
"from 12:34:56 port 22": "", // a time, not an address
|
||||
"from 999.1.1.1 port 22": "",
|
||||
"from 203.0.113.9 port 22 trusted-scanner": "", // ignored
|
||||
"nothing here": "",
|
||||
}
|
||||
for line, want := range cases {
|
||||
a, ok := m.Match(line)
|
||||
if got := map[bool]string{true: a.String(), false: ""}[ok]; got != want {
|
||||
t.Errorf("%q: %q, want %q", line, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := Compile([]string{"<IP> and <IP>"}, nil); err == nil {
|
||||
t.Error("two <IP> accepted")
|
||||
}
|
||||
if _, err := Compile([]string{"<IP> (unclosed"}, nil); err == nil {
|
||||
t.Error("a broken pattern accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup(t *testing.T) {
|
||||
if g := Group(netip.MustParseAddr("2001:db8:1:2:aa::1"), 64); g.String() != "2001:db8:1:2::/64" {
|
||||
t.Errorf("/64: %s", g)
|
||||
}
|
||||
if g := Group(netip.MustParseAddr("203.0.113.9"), 64); g.String() != "203.0.113.9/32" {
|
||||
t.Errorf("IPv4: %s", g)
|
||||
}
|
||||
if g := Group(netip.MustParseAddr("2001:db8::1"), 128); g.String() != "2001:db8::1/128" {
|
||||
t.Errorf("/128: %s", g)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package version holds Guard's version, set at build time with
|
||||
//
|
||||
// go build -ldflags "-X guard/internal/version.Version=1.2.3 -X guard/internal/version.Tag=v1.2.3"
|
||||
//
|
||||
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||
// is changed. Only such a build reports a version number. Other builds
|
||||
// are development builds, identified by their commit (which Go records
|
||||
// from git by itself) and by where they stand relative to the last tag
|
||||
// (Describe, from git describe).
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Version is the Guard version (the VERSION file).
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||
var Tag = ""
|
||||
|
||||
// Commit is the git commit the build is of. It is read from the build
|
||||
// information when not set at build time.
|
||||
var Commit = ""
|
||||
|
||||
// Describe is 'git describe --tags --long' of the build's commit
|
||||
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
|
||||
// commits a development build is past the last tag.
|
||||
var Describe = ""
|
||||
|
||||
// Repo is Guard's source repository.
|
||||
const Repo = "https://gitea.adbraun.lol/adam/guard"
|
||||
|
||||
// Build describes what a binary was built from.
|
||||
type Build struct {
|
||||
Version string `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||
// Since is the last tag before a development build's commit, and
|
||||
// Ahead how many commits the build is past it (0: the tag's commit,
|
||||
// with uncommitted changes).
|
||||
Since string `json:"since,omitempty"`
|
||||
Ahead int `json:"ahead,omitempty"`
|
||||
// Label is the short form to show: the tag of a release, otherwise
|
||||
// the abbreviated commit (or the version when the commit is unknown).
|
||||
Label string `json:"label"`
|
||||
// URL is the release or commit in the repository.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var info = sync.OnceValue(func() Build {
|
||||
commit, modified := Commit, false
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = s.Value
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = s.Value == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
b := describe(Version, Tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
|
||||
return b
|
||||
})
|
||||
|
||||
// parseDescribe splits git describe --long output, if it is of commit.
|
||||
func parseDescribe(d, commit string) (since string, ahead int) {
|
||||
m := describeRe.FindStringSubmatch(d)
|
||||
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
return m[1], n
|
||||
}
|
||||
|
||||
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
|
||||
|
||||
func describe(version, tag, commit string, modified bool) Build {
|
||||
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||
switch {
|
||||
case tag != "":
|
||||
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||
case commit != "":
|
||||
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||
if modified {
|
||||
b.Label += "-modified"
|
||||
}
|
||||
default:
|
||||
b.Label, b.URL = version, Repo
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Info describes this build.
|
||||
func Info() Build { return info() }
|
||||
|
||||
// String is the version for logs and 'version' commands. Only a release
|
||||
// build gives a version ("0.2.0"); any other build says it's a
|
||||
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
|
||||
func String() string { return Info().String() }
|
||||
|
||||
func (b Build) String() string {
|
||||
switch {
|
||||
case b.Tag != "":
|
||||
return b.Version
|
||||
case b.Commit == "":
|
||||
// No git information (built from a source archive).
|
||||
return b.Version + " (source build)"
|
||||
}
|
||||
s := "dev " + b.Label
|
||||
switch {
|
||||
case b.Since == "":
|
||||
case b.Ahead == 1:
|
||||
s += " (1 commit after " + b.Since + ")"
|
||||
case b.Ahead > 1:
|
||||
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
|
||||
case b.Modified:
|
||||
s += " (" + b.Since + " with uncommitted changes)"
|
||||
default:
|
||||
s += " (at " + b.Since + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Short is the version for a User-Agent: the version of a release,
|
||||
// otherwise the label ("a1b2c3d4e5f6").
|
||||
func Short() string {
|
||||
if b := Info(); b.Tag == "" && b.Commit != "" {
|
||||
return b.Label
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
# End-to-end test of Guard with Housecarl and real traffic: a router
|
||||
# container (Debian 13, nftables 1.1.3) runs housecarld between an
|
||||
# "internet" network and a LAN; a server on the LAN runs Guard over a log
|
||||
# file; a host on the internet misbehaves. Checks that Guard bans it at
|
||||
# the router (one port, for the server alone), that never_ban holds, that
|
||||
# bans wait while Housecarl is down, and that Guard on the router bans
|
||||
# scanners from Housecarl's own log of dropped packets.
|
||||
#
|
||||
# Needs Docker and ../housecarl (its image housecarl-nft, made by its
|
||||
# scripts/e2e.sh if missing). Run from the repository root:
|
||||
#
|
||||
# make e2e or scripts/e2e.sh
|
||||
#
|
||||
# KEEP=1 leaves the containers and networks running.
|
||||
set -u
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
HC=${HOUSECARL_SRC:-$ROOT/../housecarl}
|
||||
P=${E2E_PREFIX:-gde2e}
|
||||
IMAGE=${E2E_IMAGE:-housecarl-nft}
|
||||
WORK=${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/guard-e2e.XXXXXX")}
|
||||
docker info >/dev/null 2>&1 || { echo "Docker isn't reachable (set DOCKER_HOST?)"; exit 2; }
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "no image $IMAGE: run ../housecarl's make e2e once to make it"; exit 2; }
|
||||
|
||||
(cd "$ROOT" && CGO_ENABLED=0 go build -o "$WORK/guard" ./cmd/guard) || exit 1
|
||||
(cd "$HC" && CGO_ENABLED=0 go build -o "$WORK/housecarld" ./cmd/housecarld && CGO_ENABLED=0 go build -o "$WORK/hc" ./cmd/hc) || exit 1
|
||||
|
||||
cleanup() {
|
||||
if [ "${KEEP:-}" = 1 ]; then echo "left running: containers ${P}-*, networks ${P}-*, files in $WORK"; return; fi
|
||||
docker exec ${P}-router sh -c 'rm -rf /w/data /w/*.log /w/state*' >/dev/null 2>&1
|
||||
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
|
||||
docker network rm ${P}-wan ${P}-lan >/dev/null 2>&1
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
docker rm -f $(docker ps -aq --filter "name=^${P}-") >/dev/null 2>&1
|
||||
docker network rm ${P}-wan ${P}-lan >/dev/null 2>&1
|
||||
|
||||
# The internet 198.18.2.0/24, the LAN 10.89.10.0/24; the router has .1 on
|
||||
# the LAN, .2 on the internet; the server is 10.89.10.10.
|
||||
docker network create --subnet 198.18.2.0/24 --gateway 198.18.2.254 ${P}-wan >/dev/null || exit 1
|
||||
docker network create --subnet 10.89.10.0/24 --gateway 10.89.10.254 ${P}-lan >/dev/null || exit 1
|
||||
WAN4=198.18.2.2 LAN4=10.89.10.1 SRV=10.89.10.10 NET=198.18.2.10 NET2=198.18.2.11
|
||||
docker run -d --name ${P}-router --hostname router --cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \
|
||||
--network ${P}-wan --ip $WAN4 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
|
||||
docker network connect --ip $LAN4 ${P}-lan ${P}-router || exit 1
|
||||
docker exec ${P}-router sh -c 'ip link set eth0 down && ip link set eth0 name wan && ip link set wan up && ip link set eth1 down && ip link set eth1 name lan && ip link set lan up && ip route replace default via 198.18.2.254' || exit 1
|
||||
host() { # NAME NETWORK IP GATEWAY
|
||||
docker run -d --name ${P}-$1 --cap-add NET_ADMIN --network ${P}-$2 --ip $3 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
|
||||
docker exec ${P}-$1 ip route replace default via $4 || exit 1
|
||||
}
|
||||
host srv lan $SRV $LAN4
|
||||
host net wan $NET 198.18.2.254
|
||||
docker exec ${P}-net sh -c "ip addr add $NET2/24 dev eth0 && ip route replace 10.89.10.0/24 via $WAN4"
|
||||
# The server: "ssh" on 22 and "web" on 80.
|
||||
docker exec -d ${P}-srv sh -c 'while true; do echo ssh | nc -N -l 0.0.0.0 22; done'
|
||||
docker exec -d ${P}-srv sh -c 'while true; do echo web | nc -N -l 0.0.0.0 80; done'
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
run() {
|
||||
local desc=$1 want=$2; shift 2
|
||||
OUT=$("$@" 2>&1); local rc=$?
|
||||
if [ "$rc" = "$want" ]; then ok "$desc (rc=$rc)"; else bad "$desc: rc=$rc want $want"; echo "$OUT" | sed 's/^/ /'; fi
|
||||
}
|
||||
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
|
||||
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; fi; }
|
||||
# eventually DESC CMD...: retries for 15 seconds until CMD succeeds.
|
||||
eventually() {
|
||||
local desc=$1; shift
|
||||
for i in $(seq 1 30); do OUT=$("$@" 2>&1) && { ok "$desc"; return; }; sleep 0.5; done
|
||||
bad "$desc"; echo "$OUT" | sed 's/^/ /'
|
||||
}
|
||||
R() { docker exec ${P}-router "$@"; }
|
||||
H() { docker exec ${P}-router /w/hc "$@"; }
|
||||
connect() { docker exec ${P}-net nc -w 3 ${2:+-s $2} $SRV $1 </dev/null; }
|
||||
fails() { # LINES SOURCE: sshd's failed logins
|
||||
for i in $(seq 1 $1); do echo "$(date '+%b %e %T') srv sshd-session[$i]: Failed password for invalid user admin from $2 port 4$i ssh2"; done >> "$WORK/auth.log"
|
||||
}
|
||||
|
||||
cat > "$WORK/housecarl.yaml" <<'EOF'
|
||||
listen: ["0.0.0.0:8070"]
|
||||
database: /w/data/housecarl.db
|
||||
ruleset: /w/data/ruleset.nft
|
||||
log: {level: debug}
|
||||
stats: {enabled: false}
|
||||
EOF
|
||||
start_hc() { R sh -c "(/w/housecarld -c /w/housecarl.yaml serve >> /w/housecarl.log 2>&1 &); for i in \$(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1"; }
|
||||
stop_hc() { R sh -c 'kill $(pidof housecarld)'; sleep 1; }
|
||||
|
||||
# ---- Housecarl: a router with a ban list for the server
|
||||
|
||||
run "housecarld starts" 0 start_hc
|
||||
run "zones" 0 sh -c "docker exec ${P}-router /w/hc zone add wan --interface wan --external && docker exec ${P}-router /w/hc zone add lan --interface lan"
|
||||
run "the API from the LAN" 0 H rule add input --in lan --service tcp/8070
|
||||
run "the internet reaches the server's ssh and web" 0 H rule add forward --in wan --to $SRV --service tcp/22 --service tcp/80
|
||||
run "the LAN goes out" 0 H rule add forward --in lan --out wan
|
||||
run "a ban list for the server" 0 H banlist add srv --scope hosts --host $SRV
|
||||
run "a user for the server's Guard" 0 H user add guard-srv --no-password --grant bans:write:srv
|
||||
R sh -c "/w/hc token create srv --user guard-srv > /w/token 2>/dev/null"
|
||||
run "its token" 0 test -s "$WORK/token"
|
||||
run "ssh from the internet" 0 connect 22
|
||||
contains "^ssh$"
|
||||
|
||||
# ---- Guard on the server, over a log file
|
||||
|
||||
: > "$WORK/auth.log"
|
||||
cat > "$WORK/guard.yaml" <<EOF
|
||||
housecarl: {url: "http://$LAN4:8070", token_file: /w/token, list: srv}
|
||||
never_ban: [$NET2]
|
||||
defaults: {hits: 3, within: 10m, ban: 1h}
|
||||
watch:
|
||||
- name: ssh
|
||||
preset: sshd
|
||||
file: /w/auth.log
|
||||
ban: {protocol: tcp, port: ssh}
|
||||
state: /w/state.json
|
||||
socket: /w/guard.sock
|
||||
log: {level: debug}
|
||||
EOF
|
||||
run "guard check" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml check
|
||||
contains "ban list srv: ok (scope hosts, IPv6 banned by /64)"
|
||||
docker exec -d ${P}-srv sh -c '/w/guard -c /w/guard.yaml run >> /w/guard.log 2>&1'
|
||||
sleep 2
|
||||
fails 2 $NET
|
||||
sleep 2
|
||||
run "two failures: no ban yet" 0 H bans srv
|
||||
not_contains "$NET"
|
||||
fails 1 $NET
|
||||
eventually "the third: banned at the router" sh -c "docker exec ${P}-router /w/hc bans srv | grep -q '$NET *tcp/22 .*guard-srv *guard/ssh'"
|
||||
run "ssh from it is cut" 1 connect 22
|
||||
run "its web isn't (tcp/22 only)" 0 connect 80
|
||||
contains "^web$"
|
||||
run "guard status" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml status
|
||||
contains "ssh *file /w/auth.log *3 *3 *1"
|
||||
contains "$NET *srv *tcp/ssh *1h *banned"
|
||||
fails 5 $NET2
|
||||
sleep 3
|
||||
run "never_ban: not banned" 0 H bans srv
|
||||
not_contains "$NET2"
|
||||
run "ssh from it still works" 0 connect 22 $NET2
|
||||
contains "^ssh$"
|
||||
run "guard test over the log" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml test ssh /w/auth.log
|
||||
contains "would ban 1"
|
||||
contains "never banned: in never_ban $NET2/32"
|
||||
run "guard unban" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml unban $NET
|
||||
contains "1 bans lifted"
|
||||
run "ssh again" 0 connect 22
|
||||
contains "^ssh$"
|
||||
|
||||
# ---- Housecarl away: the ban waits
|
||||
|
||||
stop_hc
|
||||
fails 3 $NET
|
||||
sleep 3
|
||||
run "the ban waits" 0 docker exec ${P}-srv /w/guard -c /w/guard.yaml status
|
||||
contains "1 bans wait for Housecarl"
|
||||
run "housecarld starts again" 0 start_hc
|
||||
eventually "the ban that waited arrives" sh -c "docker exec ${P}-router /w/hc bans srv | grep -q '$NET *tcp/22'"
|
||||
run "its time grew: a repeat offender" 0 H bans srv
|
||||
contains "$NET *tcp/22 *1h59m\|$NET *tcp/22 *2h"
|
||||
run "ssh from it is cut again" 1 connect 22
|
||||
run "lift it" 0 H unban srv $NET
|
||||
|
||||
# ---- Guard on the router: scans in Housecarl's log
|
||||
|
||||
run "a ban list for the router" 0 H banlist add edge --scope everything
|
||||
run "a logged drop for a scanned port" 0 H rule add input --in wan --service tcp/9999 -a drop --log
|
||||
run "a user for the router's Guard" 0 H user add guard-edge --no-password --grant bans:write:edge --grant log:read
|
||||
R sh -c "/w/hc token create edge --user guard-edge > /w/token-edge 2>/dev/null"
|
||||
cat > "$WORK/guard-edge.yaml" <<EOF
|
||||
housecarl: {socket: /run/housecarl/housecarl.sock, token_file: /w/token-edge, list: edge}
|
||||
watch:
|
||||
- {name: scans, preset: housecarl-drops, hits: 4, within: 1m, ban: {time: 1d}}
|
||||
state: /w/state-edge.json
|
||||
socket: /w/guard-edge.sock
|
||||
EOF
|
||||
run "guard check on the router" 0 R /w/guard -c /w/guard-edge.yaml check
|
||||
contains "Housecarl's log (housecarl-drops): ok"
|
||||
docker exec -d ${P}-router sh -c '/w/guard -c /w/guard-edge.yaml run >> /w/guard-edge.log 2>&1'
|
||||
sleep 2
|
||||
for i in 1 2 3 4; do docker exec ${P}-net nc -w 1 $WAN4 9999 </dev/null >/dev/null 2>&1; done
|
||||
eventually "the scanner is banned from everything" sh -c "docker exec ${P}-router /w/hc bans edge | grep -q '$NET *all .*guard/scans'"
|
||||
run "and can't reach the server at all" 1 connect 80
|
||||
|
||||
echo
|
||||
echo "RESULT: $PASS passed, $FAIL failed"
|
||||
[ $FAIL = 0 ]
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
# Builds Guard's Debian package, dist/guard_<version>_<arch>.deb:
|
||||
#
|
||||
# /usr/bin/guard and its bash completion
|
||||
# /usr/lib/systemd/system/guard.service, /usr/lib/sysusers.d/guard.conf
|
||||
# /usr/share/doc/guard/ (README, CHANGELOG, docs)
|
||||
#
|
||||
# Its scripts (deploy/debian) create the guard user and an example
|
||||
# /etc/guard/guard.yaml on the first install, restart a running guard on
|
||||
# upgrades, and stop it on removal. No file in /etc belongs to the
|
||||
# package, so an upgrade never asks about configuration files.
|
||||
#
|
||||
# Run through 'make deb'; needs only dpkg-deb, no root and no debhelper.
|
||||
set -eu
|
||||
|
||||
: "${DEB_ARCH:?}" "${DEB_VERSION:?}" "${VERSION:?}" "${LDFLAGS:?}"
|
||||
GOFLAGS=${GOFLAGS:-}
|
||||
|
||||
goarm=
|
||||
case "$DEB_ARCH" in
|
||||
amd64) goarch=amd64 ;;
|
||||
arm64) goarch=arm64 ;;
|
||||
armhf) goarch=arm goarm=7 ;;
|
||||
i386) goarch=386 ;;
|
||||
ppc64el) goarch=ppc64le ;;
|
||||
riscv64) goarch=riscv64 ;;
|
||||
*) echo "mkdeb: unsupported architecture $DEB_ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -z "${DEB_MAINTAINER:-}" ]; then
|
||||
name=$(git config user.name 2>/dev/null || true)
|
||||
email=$(git config user.email 2>/dev/null || true)
|
||||
DEB_MAINTAINER="${name:-Guard packager} <${email:-root@localhost}>"
|
||||
fi
|
||||
|
||||
work=dist/deb/${DEB_VERSION}_${DEB_ARCH}
|
||||
root=$work/guard
|
||||
rm -rf "$work"
|
||||
rm -f dist/guard_*_"$DEB_ARCH".deb
|
||||
mkdir -p "$root"
|
||||
|
||||
echo "mkdeb: building for linux/$goarch"
|
||||
export GOFLAGS
|
||||
GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/bin/guard" ./cmd/guard
|
||||
make --no-print-directory install-files DESTDIR="$root" PREFIX=/usr UNITDIR=/usr/lib/systemd/system GUARD_BIN="$work/bin/guard" >/dev/null
|
||||
mkdir -p "$root/DEBIAN"
|
||||
for f in postinst prerm postrm; do
|
||||
install -m 0755 "deploy/debian/$f" "$root/DEBIAN/$f"
|
||||
done
|
||||
|
||||
indent() { sed -e 's/^$/./' -e 's/^/ /' "$1"; }
|
||||
{
|
||||
echo "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
|
||||
echo "Upstream-Name: guard"
|
||||
echo
|
||||
echo "Files: *"
|
||||
echo "Copyright: $(sed -n 's/^Copyright (c) //p' LICENSE)"
|
||||
echo "License: Expat"
|
||||
indent LICENSE
|
||||
} > "$root/usr/share/doc/guard/copyright"
|
||||
rm -f "$root/usr/share/doc/guard/LICENSE"
|
||||
find "$root" -type d -exec chmod 0755 {} +
|
||||
chmod -R go-w "$root"
|
||||
size=$(du -sk --exclude=DEBIAN "$root" | cut -f1)
|
||||
sed -e "s/@VERSION@/$DEB_VERSION/g" -e "s/@ARCH@/$DEB_ARCH/" -e "s/@SIZE@/$size/" \
|
||||
-e "s|@MAINTAINER@|$DEB_MAINTAINER|" deploy/debian/control.in > "$root/DEBIAN/control"
|
||||
(cd "$root" && find . -path ./DEBIAN -prune -o -type f -print | sed 's|^\./||' | sort | xargs md5sum) > "$root/DEBIAN/md5sums"
|
||||
chmod 0644 "$root/DEBIAN/control" "$root/DEBIAN/md5sums"
|
||||
out=dist/guard_${DEB_VERSION}_${DEB_ARCH}.deb
|
||||
dpkg-deb --root-owner-group -Zxz --build "$root" "$out" >/dev/null
|
||||
echo "mkdeb: wrote $out"
|
||||
dpkg-deb --info "$out" | sed -n 's/^ \(Package\|Version\|Architecture\|Installed-Size\|Depends\):/ \1:/p'
|
||||
rm -rf dist/deb
|
||||
Vendored
+4
@@ -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
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
203.0.113.9 imap-login: Disconnected: Connection closed (auth failed, 3 attempts in 12 secs): user=<admin>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, TLS, session=<abc>
|
||||
203.0.113.9 pop3-login: Aborted login (auth failed, 1 attempts in 2 secs): user=<test>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session=<abc>
|
||||
203.0.113.9 imap-login: Disconnected (auth failed, 1 attempts in 2 secs): user=<a>, rip=8.8.8.8, x>, method=PLAIN, rip=203.0.113.9, lip=10.0.10.25, session=<abc>
|
||||
- imap-login: Login: user=<adam>, method=PLAIN, rip=10.0.10.20, lip=10.0.10.25, mpid=1234, TLS
|
||||
Vendored
+6
@@ -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
|
||||
Vendored
+6
@@ -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
|
||||
Vendored
+3
@@ -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
|
||||
Vendored
+3
@@ -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
|
||||
Vendored
+15
@@ -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.
|
||||
Vendored
+6
@@ -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
|
||||
Reference in new issue
Block a user