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