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>
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|