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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user