Guard: watch logs, ban misbehaving addresses in Housecarl

Files and the journal, patterns with one <IP>, bounties within a window,
bans in a Housecarl ban list with repeat offenders' bans growing; presets
for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot,
nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban;
a Debian package with a sandboxed unit; an end-to-end test with Housecarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
commit d06bf8d531
47 files changed
+4614

No files matched your search

+690
View File
@@ -0,0 +1,690 @@
// Command guard watches logs and bans the addresses that misbehave in
// them, at the firewall, through Housecarl's ban lists. One binary: the
// daemon (guard run) and the commands to check, test and see it.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"guard/internal/config"
"guard/internal/daemon"
"guard/internal/housecarl"
"guard/internal/match"
"guard/internal/source"
"guard/internal/state"
"guard/internal/version"
)
var (
flagConfig string
flagJSON bool
)
func main() {
root := &cobra.Command{
Use: "guard",
Short: "Guard watches logs and bans misbehaving addresses at the firewall (Housecarl)",
Long: `Guard watches log files and the systemd journal for misdeeds (failed
logins, scans), counts each address's bounty, and once it reaches a
watch's hits bans the address in a Housecarl ban list: the firewall then
keeps it from this machine (or from what the list says).
guard check check guard.yaml and reach Housecarl
guard test ssh /var/log/auth.log what a watch's patterns find in a log
guard status the watches, their highest bounties, recent bans
Exit codes: 0 ok, 1 error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
root.PersistentFlags().StringVarP(&flagConfig, "config", "c", config.DefaultPath, "configuration file")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "print JSON instead of tables")
root.AddCommand(runCmd(), checkCmd(), configCmd(), presetsCmd(), testCmd(), statusCmd(), bansCmd(), banCmd(), unbanCmd(), versionCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "guard:", err)
os.Exit(1)
}
}
func load() (*config.Config, error) { return config.Load(flagConfig) }
// client makes the Housecarl client of a configuration.
func client(cfg *config.Config) (*housecarl.Client, error) {
h := cfg.Housecarl
tok := h.Token
if h.TokenFile != "" {
b, err := os.ReadFile(h.TokenFile)
if err != nil {
return nil, fmt.Errorf("the token: %w", err)
}
tok = strings.TrimSpace(string(b))
}
return housecarl.New(housecarl.Options{URL: h.URL, Socket: h.Socket, Token: tok, CACert: h.CACert, Insecure: h.Insecure})
}
func logger(cfg *config.Config) *slog.Logger {
var lv slog.Level
lv.UnmarshalText([]byte(cfg.Log.Level))
opts := &slog.HandlerOptions{Level: lv}
if cfg.Log.Format == "json" {
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}
return slog.New(slog.NewTextHandler(os.Stderr, opts))
}
func table(f func(w io.Writer)) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
f(w)
w.Flush()
}
func printJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// ctx is the commands' context: each request has the client's timeout.
func ctx() context.Context { return context.Background() }
// ---- run
func runCmd() *cobra.Command {
return &cobra.Command{
Use: "run", Short: "Watch the logs and ban (the daemon; systemd runs it)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
log := logger(cfg)
hc, err := client(cfg)
if err != nil {
return err
}
st, err := state.Load(cfg.State)
if err != nil {
return fmt.Errorf("the state %s: %w", cfg.State, err)
}
d, err := daemon.New(cfg, hc, hc, st, log)
if err != nil {
return err
}
c, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Info("starting", "version", version.String(), "config", flagConfig)
return d.Run(c)
},
}
}
// ---- check
func checkCmd() *cobra.Command {
return &cobra.Command{
Use: "check", Short: "Check the configuration, and that Housecarl answers", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
fmt.Printf("%s: ok\n", flagConfig)
lists := map[string]bool{}
drops := false
table(func(w io.Writer) {
fmt.Fprintln(w, "WATCH\tSOURCE\tPATTERNS\tBAN AFTER\tLIST\tPORTS")
for _, x := range cfg.Watch {
if x.Disabled {
fmt.Fprintf(w, "%s\t(disabled)\t\t\t\t\n", x.Name)
continue
}
lists[x.Ban.List] = true
drops = drops || x.Kind() == config.SourceHousecarlDrops
fmt.Fprintf(w, "%s\t%s\t%d\t%d in %s\t%s\t%s\n", x.Name, daemon.Describe(x), len(x.Match), x.Hits, x.Within, x.Ban.List, ports(x.Ban.Protocol, x.Ban.Port))
}
})
hc, err := client(cfg)
if err != nil {
return err
}
bad := false
for l := range lists {
bl, err := hc.BanList(ctx(), l)
switch {
case err != nil:
fmt.Printf("ban list %s: %v\n", l, err)
bad = true
case bl.Disabled:
fmt.Printf("ban list %s: disabled in Housecarl (bans aren't in force)\n", l)
default:
fmt.Printf("ban list %s: ok (scope %s, IPv6 banned by /%d)\n", l, bl.Scope, bl.V6Group())
}
}
if drops {
if _, err := hc.Log(ctx(), time.Now()); err != nil {
fmt.Printf("Housecarl's log (housecarl-drops): %v (the token needs log:read)\n", err)
bad = true
} else {
fmt.Println("Housecarl's log (housecarl-drops): ok")
}
}
if bad {
return errors.New("Housecarl isn't ready for these watches")
}
return nil
},
}
}
func ports(proto string, p []string) string {
if len(p) == 0 {
return "all"
}
if proto == "" {
return strings.Join(p, ",")
}
return strings.ToLower(proto) + "/" + strings.Join(p, ",")
}
// ---- config
func configCmd() *cobra.Command {
var example bool
cmd := &cobra.Command{
Use: "config", Short: "Print the configuration as read (--example: a commented one)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if example {
fmt.Print(config.Example())
return nil
}
cfg, err := load()
if err != nil {
return err
}
return printJSON(cfg)
},
}
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
return cmd
}
// ---- presets
func presetsCmd() *cobra.Command {
return &cobra.Command{
Use: "presets [PRESET]", Short: "List the presets, or show one's patterns", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ps := config.Presets()
if len(args) == 1 {
p, ok := ps[args[0]]
if !ok {
return fmt.Errorf("there is no preset %q", args[0])
}
if flagJSON {
return printJSON(p)
}
w := &config.Watch{Preset: args[0], File: p.File, Journal: p.Journal, Source: p.Source}
fmt.Printf("%s: %s\nSource: %s\n", args[0], p.Description, daemon.Describe(w))
for _, m := range p.Match {
fmt.Printf("Match: %s\n", m)
}
for _, m := range p.Ignore {
fmt.Printf("Ignore: %s\n", m)
}
return nil
}
var names []string
for n := range ps {
names = append(names, n)
}
sort.Strings(names)
if flagJSON {
return printJSON(ps)
}
table(func(w io.Writer) {
fmt.Fprintln(w, "PRESET\tSOURCE\tWHAT")
for _, n := range names {
p := ps[n]
fmt.Fprintf(w, "%s\t%s\t%s\n", n, daemon.Describe(&config.Watch{File: p.File, Journal: p.Journal, Source: p.Source, Protocol: "tcp"}), p.Description)
}
})
return nil
},
}
}
// ---- test
func testCmd() *cobra.Command {
var lines int
var all bool
cmd := &cobra.Command{
Use: "test WATCH [FILE|-]",
Short: "Run a watch's patterns over a log: what they match, whom they'd ban",
Long: `Runs a watch's patterns over a log, from its start: a file, standard
input (-), or for a journal watch without FILE its last --lines entries.
It prints each matching line's address, and the addresses that would be
banned. Nothing is banned.
A file's lines carry no time Guard reads, so for a file every match
counts, however far apart; the journal's entries have their times, and
the watch's window (within) is used.`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
var w *config.Watch
for _, x := range cfg.Watch {
if strings.EqualFold(x.Name, args[0]) {
w = x
}
}
if w == nil {
return fmt.Errorf("there is no watch %q in %s", args[0], flagConfig)
}
if w.Kind() == config.SourceHousecarlDrops {
return errors.New("a housecarl-drops watch has no patterns to test: every dropped packet counts")
}
m, err := match.Compile(w.Match, w.Ignore)
if err != nil {
return err
}
var in []source.Line
timed := false
switch {
case len(args) == 2:
in, err = readLines(args[1])
case w.Kind() == "journal":
in, err = readJournal(w, lines)
timed = true
default:
in, err = readLines(w.File)
}
if err != nil {
return err
}
never := daemon.NewNeverBan(cfg.NeverBan, nil)
type hit struct {
count int
ban bool
}
hits := map[string]*hit{}
order := []string{}
tr := map[string][]time.Time{}
matched := 0
for _, l := range in {
a, ok := m.Match(l.Text)
if !ok {
continue
}
matched++
why := never.Why(a)
if all || matched <= 200 {
note := ""
if why != "" {
note = " (never banned: " + why + ")"
}
fmt.Printf("%-39s %s%s\n", a, clip(l.Text, 160), note)
}
if why != "" {
continue
}
g := match.Group(a, 64).String()
h := hits[g]
if h == nil {
h = &hit{}
hits[g] = h
order = append(order, g)
}
h.count++
if timed {
ts := append(tr[g], l.Time)
for len(ts) > 0 && ts[0].Before(l.Time.Add(-w.Within.D())) {
ts = ts[1:]
}
tr[g] = ts
h.ban = h.ban || len(ts) >= w.Hits
} else {
h.ban = h.ban || h.count >= w.Hits
}
}
if matched > 200 && !all {
fmt.Printf("… %d more matching lines (--all shows them)\n", matched-200)
}
fmt.Printf("\n%d lines, %d matched, %d addresses (IPv6 by /64)\n", len(in), matched, len(order))
sort.SliceStable(order, func(i, j int) bool { return hits[order[i]].count > hits[order[j]].count })
banned := 0
for _, g := range order {
if hits[g].ban {
banned++
}
}
if banned == 0 {
fmt.Printf("nobody would be banned (%d hits within %s)\n", w.Hits, w.Within)
return nil
}
window := "within " + w.Within.String()
if !timed {
window = "in all (a file's lines have no time)"
}
fmt.Printf("would ban %d (%d hits %s):\n", banned, w.Hits, window)
for _, g := range order {
if hits[g].ban {
fmt.Printf(" %-39s %d matches\n", g, hits[g].count)
}
}
return nil
},
}
cmd.Flags().IntVarP(&lines, "lines", "n", 5000, "a journal watch without FILE: the last this many entries")
cmd.Flags().BoolVar(&all, "all", false, "print every matching line, not the first 200")
return cmd
}
func readLines(path string) ([]source.Line, error) {
var r io.Reader = os.Stdin
if path != "-" {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
r = f
}
var out []source.Line
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
out = append(out, source.Line{Text: sc.Text()})
}
return out, sc.Err()
}
func readJournal(w *config.Watch, n int) ([]source.Line, error) {
c := exec.Command(source.JournalCommand(), source.JournalArgs(w.Journal.Unit, w.Journal.Identifier, false, "", n)...)
c.Stderr = os.Stderr
outp, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
ch := make(chan source.Line, 64)
done := make(chan []source.Line)
go func() {
var out []source.Line
for l := range ch {
out = append(out, l)
}
done <- out
}()
source.ReadJournal(context.Background(), outp, ch, "")
close(ch)
lines := <-done
if err := c.Wait(); err != nil {
return nil, fmt.Errorf("journalctl: %w (Guard's user needs the systemd-journal group)", err)
}
return lines, nil
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
// ---- status
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "The running Guard: its watches, highest bounties, recent bans", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
hc := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{DialContext: func(c context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(c, "unix", cfg.Socket)
}}}
resp, err := hc.Get("http://guard/status")
if err != nil {
return fmt.Errorf("Guard isn't running, or %s can't be reached (as root, or the guard user): %w", cfg.Socket, err)
}
defer resp.Body.Close()
var s daemon.Status
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return err
}
if flagJSON {
return printJSON(s)
}
fmt.Printf("Running since %s (%s)\n\n", s.Started.Local().Format("2006-01-02 15:04"), config.FormatDuration(time.Since(s.Started).Round(time.Second)))
table(func(w io.Writer) {
fmt.Fprintln(w, "WATCH\tSOURCE\tLINES\tMATCHES\tBANS\tBAN AFTER\tHIGHEST BOUNTIES")
for _, x := range s.Watches {
var top []string
for i, b := range x.Top {
if i == 3 {
break
}
top = append(top, fmt.Sprintf("%s %d/%d", b.Address, b.Bounty, x.Hits))
}
fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%d\t%d in %s\t%s\n", x.Name, x.Source, x.Lines, x.Matches, x.Bans, x.Hits, x.Within, strings.Join(top, ", "))
}
})
if len(s.Pending) > 0 {
fmt.Printf("\n%d bans wait for Housecarl\n", len(s.Pending))
}
if len(s.Recent) > 0 {
fmt.Println("\nRecent bans:")
table(func(w io.Writer) {
fmt.Fprintln(w, "TIME\tWATCH\tADDRESS\tLIST\tPORTS\tFOR\tRESULT")
for i, b := range s.Recent {
if i == 20 {
break
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Time.Local().Format("01-02 15:04"), b.Watch, b.Address, b.List,
ports(b.Protocol, splitComma(b.Ports)), config.FormatDuration(time.Duration(b.Seconds)*time.Second), clip(b.Result, 60))
}
})
}
return nil
},
}
}
func splitComma(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
// ---- bans in Housecarl
func listFlag(cfg *config.Config, list string) (string, error) {
if list != "" {
return list, nil
}
if cfg.Housecarl.List == "" {
return "", errors.New("which ban list? Give --list, or set housecarl.list")
}
return cfg.Housecarl.List, nil
}
func bansCmd() *cobra.Command {
var list string
cmd := &cobra.Command{
Use: "bans", Short: "The active bans in Housecarl (of the ban list, or --list)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
hc, err := client(cfg)
if err != nil {
return err
}
bans, err := hc.Bans(ctx(), l)
if err != nil {
return err
}
if flagJSON {
return printJSON(bans)
}
table(func(w io.Writer) {
fmt.Fprintln(w, "ID\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE")
for _, b := range bans {
port := "all"
if b.Protocol != "" {
port = b.Protocol + "/" + strconv.Itoa(b.Port)
}
ends := "never"
if b.Timeout > 0 {
ends = config.FormatDuration(time.Duration(b.Timeout) * time.Second)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", b.ID, b.Address, port, ends, b.Who, b.Source)
}
})
return nil
},
}
cmd.Flags().StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
return cmd
}
func banCmd() *cobra.Command {
var list, t, reason, proto string
var port []string
cmd := &cobra.Command{
Use: "ban ADDRESS", Short: "Ban an address by hand", Args: cobra.ExactArgs(1),
Long: `Bans an address in Housecarl, as a watch would: every port, or with
--protocol and --port some (numbers or service names).
guard ban 203.0.113.9 --time 1d
guard ban 203.0.113.9 --protocol tcp --port 22`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
if _, err := netip.ParseAddr(strings.Trim(args[0], "[]")); err != nil {
return fmt.Errorf("%q isn't an address", args[0])
}
in := housecarl.BanInput{Address: args[0], Protocol: proto, Ports: strings.Join(port, ","), Source: "guard (by hand)", Reason: reason}
if t != "" {
d, err := config.ParseDuration(t)
if err != nil {
return err
}
in.Time = int(d / time.Second)
}
hc, err := client(cfg)
if err != nil {
return err
}
res, err := hc.Ban(ctx(), l, in)
if err != nil {
return err
}
if flagJSON {
return printJSON(res)
}
for _, b := range res.Bans {
what := "banned"
if b.Existing {
what = "renewed"
}
port := "every port"
if b.Protocol != "" {
port = b.Protocol + "/" + strconv.Itoa(b.Port)
}
fmt.Printf("%s %s, %s, in %s, for %s\n", what, b.Address, port, b.List, config.FormatDuration(time.Duration(b.Timeout)*time.Second))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
f.StringVar(&proto, "protocol", "", "tcp or udp (with --port)")
f.StringArrayVar(&port, "port", nil, "a port or service (repeat; default: every port)")
f.StringVar(&t, "time", "", "how long (1h, 1d; default: the list's)")
f.StringVar(&reason, "reason", "", "why, for the record")
return cmd
}
func unbanCmd() *cobra.Command {
var list string
cmd := &cobra.Command{
Use: "unban ADDRESS", Short: "Lift an address's bans in the ban list", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := load()
if err != nil {
return err
}
l, err := listFlag(cfg, list)
if err != nil {
return err
}
hc, err := client(cfg)
if err != nil {
return err
}
n, err := hc.Unban(ctx(), l, args[0])
if err != nil {
return err
}
fmt.Printf("%d bans lifted\n", n)
return nil
},
}
cmd.Flags().StringVar(&list, "list", "", "the ban list (default: housecarl.list)")
return cmd
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
if flagJSON {
printJSON(version.Info())
return
}
fmt.Println("guard", version.String())
},
}
}