Reload guard.yaml without losing the bounties

systemctl reload guard, guard reload or SIGHUP reads the configuration
again: a watch keeps its bounties and counts under the same name and
takes its new settings at once; its source starts again only if it
changed; watches gone stop, new ones start; a file with a mistake is
refused. The Housecarl connection, state, socket and log wait for a
restart, and guard reload says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-25 01:32:20 +00:00
1 parent 9c655abae2
commit ecf4129877
11 files changed
+607 -93

No files matched your search

+137
View File
@@ -177,3 +177,140 @@ socket: ` + filepath.Join(dir, "guard.sock") + `
t.Errorf("saved state: %v %+v", err, st2.Offences)
}
}
// stoppable is a chanSource that says when it stopped.
type stoppable struct {
chanSource
stopped chan struct{}
}
func (s *stoppable) Run(ctx context.Context, out chan<- source.Line) error {
err := s.chanSource.Run(ctx, out)
close(s.stopped)
return err
}
// TestReload: a reload keeps the bounties of a watch under the same name
// and applies its new settings; a watch whose source changed gets a new
// one; watches gone stop, new ones start; settings for a restart are
// reported; a configuration with nothing to watch is refused.
func TestReload(t *testing.T) {
dir := t.TempDir()
base := `housecarl: {url: https://fw:8070, list: web}
defaults: {hits: 3, within: 10m, ban: 1h}
state: ` + filepath.Join(dir, "state.json") + `
socket: ` + filepath.Join(dir, "guard.sock") + `
`
parse := func(extra string) *config.Config {
t.Helper()
cfg, err := config.Parse([]byte(base + extra))
if err != nil {
t.Fatal(err)
}
return cfg
}
cfg := parse(`watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log}
- {name: web, file: /var/log/web.log, match: ['bad <IP>$']}
`)
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)
}
var mu sync.Mutex
srcs := map[string]*stoppable{}
d.Sources = func(w *config.Watch) source.Source {
mu.Lock()
defer mu.Unlock()
s := &stoppable{chanSource{make(chan source.Line)}, make(chan struct{})}
srcs[w.Name+" "+w.File] = s
return s
}
src := func(key string) *stoppable {
mu.Lock()
defer mu.Unlock()
return srcs[key]
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { d.Run(ctx); close(done) }()
waitFor(t, "the sources", func() bool { return src("ssh /var/log/auth.log") != nil && src("web /var/log/web.log") != nil })
fail := func(s *stoppable, addr string, n int) {
for i := 0; i < n; i++ {
s.ch <- source.Line{Text: "Failed password for root from " + addr + " port 4000 ssh2", Time: time.Now()}
}
}
ssh := src("ssh /var/log/auth.log")
fail(ssh, "203.0.113.9", 2)
res, err := d.Reload(parse(`never_ban: [198.51.100.0/24]
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, ban: {time: 2h}}
- {name: mail, preset: dovecot, file: /var/log/mail.log}
`))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(res.Changes, "; "); got != "never_ban; watch ssh: ban; watch mail: added; watch web: removed" || len(res.Restart) != 0 {
t.Errorf("reload: %q, restart %v", got, res.Restart)
}
select {
case <-src("web /var/log/web.log").stopped:
case <-time.After(2 * time.Second):
t.Error("the removed watch's source still runs")
}
if src("mail /var/log/mail.log") == nil {
t.Error("the added watch has no source")
}
// The bounty of 2 was kept: one more bans, for the new time.
fail(ssh, "203.0.113.9", 1)
waitFor(t, "the ban after the reload", func() bool { return hc.count() == 1 })
if b := hc.last(); b.Time < 7190 || b.Time > 7200 {
t.Errorf("ban after reload: %ds, want 2h", b.Time)
}
fail(ssh, "198.51.100.7", 3)
// A new file: a new source; the bounty stays; the hits change.
fail(ssh, "203.0.113.50", 2)
res, err = d.Reload(parse(`log: {level: debug}
watch:
- {name: ssh, preset: sshd, file: /var/log/secure, hits: 4, ban: {time: 2h}}
`))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(res.Changes, "; "); got != "never_ban; watch ssh: bounty 4 in 10m, source (started again); watch mail: removed" ||
strings.Join(res.Restart, ",") != "log" {
t.Errorf("reload: %q, restart %v", got, res.Restart)
}
select {
case <-ssh.stopped:
case <-time.After(2 * time.Second):
t.Error("the old source still runs")
}
secure := src("ssh /var/log/secure")
fail(secure, "203.0.113.50", 1)
time.Sleep(50 * time.Millisecond)
if hc.count() != 1 {
t.Fatal("banned below the new hits")
}
fail(secure, "203.0.113.50", 1)
waitFor(t, "the ban at 4", func() bool { return hc.count() == 2 && hc.last().Address == "203.0.113.50" })
if d.config().Log.Level != "info" {
t.Error("the log level changed without a restart")
}
// Nothing to watch: refused, the old configuration stays.
if _, err := d.Reload(parse(`watch:
- {name: ssh, preset: sshd, disabled: true}
`)); err == nil {
t.Error("a configuration with every watch disabled was taken")
}
if s := d.Status(); len(s.Watches) != 1 || s.Watches[0].Hits != 4 || s.Watches[0].Bans != 2 || s.Watches[0].Lines != 10 {
t.Errorf("status after reloads %+v", s.Watches)
}
cancel()
<-done
}