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) } }