Files
abrown38andClaude Opus 5.5 ecf4129877 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>
2026-10-25 01:32:20 +00:00

186 lines
5.2 KiB
Go

package daemon
import (
"errors"
"fmt"
"reflect"
"slices"
"strings"
"guard/internal/bounty"
"guard/internal/config"
"guard/internal/match"
)
// Reloaded is what a reload changed.
type Reloaded struct {
Changes []string `json:"changes"`
// Restart are the changed settings a reload can't apply: they wait
// for a restart.
Restart []string `json:"restart,omitempty"`
}
// ReloadFile reads the configuration again (Load) and reloads it.
func (d *Daemon) ReloadFile() (*Reloaded, error) {
if d.Load == nil {
return nil, errors.New("this Guard can't read its configuration again")
}
cfg, err := d.Load()
if err != nil {
return nil, err
}
return d.Reload(cfg)
}
// Reload puts a new configuration to work without losing what Guard
// counted: a watch keeps its bounties and counts under the same name,
// and its source runs on unless it changed. Watches gone stop, new ones
// start. The housecarl connection, state, socket and log wait for a
// restart. A configuration with no watch at work is refused, and the
// old one stays.
func (d *Daemon) Reload(cfg *config.Config) (*Reloaded, error) {
if d.ctx == nil {
return nil, errors.New("Guard isn't running")
}
type next struct {
cfg *config.Watch
m *match.Matcher
}
var want []next
for _, w := range cfg.Watch {
if w.Disabled {
continue
}
m, err := match.Compile(w.Match, w.Ignore)
if err != nil {
return nil, fmt.Errorf("watch %s: %v", w.Name, err)
}
want = append(want, next{w, m})
}
if len(want) == 0 {
return nil, errors.New("every watch is disabled: nothing to do (the configuration stays as it was)")
}
res := &Reloaded{}
d.mu.Lock()
old := d.cfg
oh, nh := old.Housecarl, cfg.Housecarl
oh.List, nh.List = "", "" // the default list only fills in watches
if oh != nh {
res.Restart = append(res.Restart, "housecarl")
}
if old.State != cfg.State {
res.Restart = append(res.Restart, "state")
}
if old.Socket != cfg.Socket {
res.Restart = append(res.Restart, "socket")
}
if old.Log != cfg.Log {
res.Restart = append(res.Restart, "log")
}
cfg.Housecarl.URL, cfg.Housecarl.Socket, cfg.Housecarl.TokenFile, cfg.Housecarl.Token = old.Housecarl.URL, old.Housecarl.Socket, old.Housecarl.TokenFile, old.Housecarl.Token
cfg.Housecarl.CACert, cfg.Housecarl.Insecure = old.Housecarl.CACert, old.Housecarl.Insecure
cfg.State, cfg.Socket, cfg.Log = old.State, old.Socket, old.Log
if !slices.Equal(old.NeverBan, cfg.NeverBan) {
n := NewNeverBan(cfg.NeverBan, d.log)
n.Lookup = d.never.Lookup
d.never.mu.RLock()
for _, h := range n.names {
if a, ok := d.never.resolved[h]; ok {
n.resolved[h] = a
}
}
d.never.mu.RUnlock()
d.neverStop()
d.never = n
d.startNever()
res.Changes = append(res.Changes, "never_ban")
}
if old.Defaults.Repeat != cfg.Defaults.Repeat {
res.Changes = append(res.Changes, "defaults.repeat: for the next bans")
}
byName := map[string]*Watch{}
for _, w := range d.watches {
byName[strings.ToLower(w.Cfg.Name)] = w
}
var watches []*Watch
var stopped []chan struct{}
for _, n := range want {
w := byName[strings.ToLower(n.cfg.Name)]
delete(byName, strings.ToLower(n.cfg.Name))
if w == nil {
w = &Watch{Cfg: n.cfg, Matcher: n.m, Tracker: bounty.New(n.cfg.Hits, n.cfg.Within.D())}
d.start(w)
watches = append(watches, w)
res.Changes = append(res.Changes, "watch "+n.cfg.Name+": added")
continue
}
watches = append(watches, w)
oc, _ := w.setup()
if reflect.DeepEqual(oc, n.cfg) {
continue
}
var what []string
if !slices.Equal(oc.Match, n.cfg.Match) || !slices.Equal(oc.Ignore, n.cfg.Ignore) {
what = append(what, "patterns")
}
if oc.Hits != n.cfg.Hits || oc.Within != n.cfg.Within {
what = append(what, fmt.Sprintf("bounty %d in %s", n.cfg.Hits, n.cfg.Within))
}
if !reflect.DeepEqual(oc.Ban, n.cfg.Ban) {
what = append(what, "ban")
}
w.Tracker.Set(n.cfg.Hits, n.cfg.Within.D())
w.mu.Lock()
w.Cfg, w.Matcher = n.cfg, n.m
w.mu.Unlock()
if sourceKey(oc) != sourceKey(n.cfg) {
w.stop()
stopped = append(stopped, w.done)
w.Source = d.newSource(n.cfg)
d.start(w)
what = append(what, "source (started again)")
}
if len(what) == 0 {
what = append(what, "changed")
}
res.Changes = append(res.Changes, "watch "+n.cfg.Name+": "+strings.Join(what, ", "))
}
for _, w := range d.watches {
if byName[strings.ToLower(w.Cfg.Name)] == w {
w.stop()
stopped = append(stopped, w.done)
res.Changes = append(res.Changes, "watch "+w.Cfg.Name+": removed")
}
}
d.watches = watches
d.cfg = cfg
d.mu.Unlock()
// A stopped watch may be handling a line, which needs d.mu.
for _, done := range stopped {
<-done
}
d.loadGroups(d.ctx)
if len(res.Changes) == 0 {
res.Changes = []string{}
}
d.log.Info("reloaded", "changes", strings.Join(res.Changes, "; "))
if len(res.Restart) > 0 {
d.log.Warn("changed settings that wait for a restart", "settings", strings.Join(res.Restart, ", "))
}
return res, nil
}
// sourceKey is what a watch's source is made from: when it changes, the
// source starts again.
func sourceKey(w *config.Watch) string {
j := ""
if w.Journal != nil {
j = fmt.Sprint(w.Journal.Unit, w.Journal.Identifier)
}
return fmt.Sprint(w.Kind(), "|", w.File, "|", j, "|", w.Source, "|", w.Protocol, "|", w.Shadow)
}