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