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

+151
View File
@@ -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
}
}
}