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>
152 lines
3.5 KiB
Go
152 lines
3.5 KiB
Go
// 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
|
|
}
|
|
}
|
|
}
|