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:
1 parent
9c655abae2
commit
ecf4129877
11 files changed
+607
-93
No files matched your search
+150
-69
@@ -37,16 +37,20 @@ type Banner interface {
|
||||
|
||||
// Daemon is a running Guard.
|
||||
type Daemon struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
hc Banner
|
||||
logs source.LogReader
|
||||
state *state.State
|
||||
never *NeverBan
|
||||
watches []*Watch
|
||||
started time.Time
|
||||
ctx context.Context // Run's, for what a reload starts
|
||||
|
||||
mu sync.Mutex
|
||||
groups map[string]int // ban list → IPv6 grouping
|
||||
mu sync.Mutex
|
||||
// cfg, never and watches change on a reload.
|
||||
cfg *config.Config
|
||||
never *NeverBan
|
||||
neverStop context.CancelFunc
|
||||
watches []*Watch
|
||||
groups map[string]int // ban list → IPv6 grouping
|
||||
// pending are the bans Housecarl hasn't taken yet, by list and group.
|
||||
pending map[string]state.Ban
|
||||
wake chan struct{}
|
||||
@@ -54,6 +58,11 @@ type Daemon struct {
|
||||
// once an hour.
|
||||
refused map[string]time.Time
|
||||
|
||||
// Load reads the configuration again for a reload (guard run sets it).
|
||||
Load func() (*config.Config, error)
|
||||
// Sources makes a watch's source (tests); nil: from its configuration.
|
||||
Sources func(*config.Watch) source.Source
|
||||
|
||||
// Now and RetryEvery can be changed by tests.
|
||||
Now func() time.Time
|
||||
RetryEvery time.Duration
|
||||
@@ -61,18 +70,30 @@ type Daemon struct {
|
||||
|
||||
// Watch is one watch at work.
|
||||
type Watch struct {
|
||||
Cfg *config.Watch
|
||||
Matcher *match.Matcher
|
||||
Source source.Source
|
||||
Tracker *bounty.Tracker
|
||||
|
||||
// mu guards Cfg and Matcher, which a reload replaces.
|
||||
mu sync.Mutex
|
||||
Cfg *config.Watch
|
||||
Matcher *match.Matcher
|
||||
|
||||
stop context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
lines, matches, bans atomic.Uint64
|
||||
}
|
||||
|
||||
func (w *Watch) setup() (*config.Watch, *match.Matcher) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.Cfg, w.Matcher
|
||||
}
|
||||
|
||||
// New makes a Daemon. hc may be a fake; sources come from the watches'
|
||||
// configuration unless a test sets them (Watch.Source).
|
||||
func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State, log *slog.Logger) (*Daemon, error) {
|
||||
d := &Daemon{cfg: cfg, log: log, hc: hc, state: st, never: NewNeverBan(cfg.NeverBan, log), groups: map[string]int{},
|
||||
d := &Daemon{cfg: cfg, log: log, hc: hc, logs: logs, state: st, never: NewNeverBan(cfg.NeverBan, log), groups: map[string]int{},
|
||||
pending: map[string]state.Ban{}, wake: make(chan struct{}, 1), refused: map[string]time.Time{},
|
||||
Now: time.Now, RetryEvery: 30 * time.Second}
|
||||
for _, b := range st.Pending {
|
||||
@@ -86,16 +107,7 @@ func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watch %s: %v", w.Name, err)
|
||||
}
|
||||
wt := &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())}
|
||||
switch w.Kind() {
|
||||
case "file":
|
||||
wt.Source = &source.File{Pattern: w.File, Log: log.With("watch", w.Name)}
|
||||
case "journal":
|
||||
wt.Source = &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log.With("watch", w.Name)}
|
||||
case config.SourceHousecarlDrops:
|
||||
wt.Source = &source.HousecarlDrops{Client: logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log.With("watch", w.Name)}
|
||||
}
|
||||
d.watches = append(d.watches, wt)
|
||||
d.watches = append(d.watches, &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())})
|
||||
}
|
||||
if len(d.watches) == 0 {
|
||||
return nil, errors.New("every watch is disabled: nothing to do")
|
||||
@@ -103,58 +115,113 @@ func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State,
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Watches are the watches at work (tests set their sources).
|
||||
func (d *Daemon) Watches() []*Watch { return d.watches }
|
||||
// newSource makes a watch's source from its configuration.
|
||||
func (d *Daemon) newSource(w *config.Watch) source.Source {
|
||||
if d.Sources != nil {
|
||||
return d.Sources(w)
|
||||
}
|
||||
log := d.log.With("watch", w.Name)
|
||||
switch w.Kind() {
|
||||
case "journal":
|
||||
return &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log}
|
||||
case config.SourceHousecarlDrops:
|
||||
return &source.HousecarlDrops{Client: d.logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log}
|
||||
}
|
||||
return &source.File{Pattern: w.File, Log: log}
|
||||
}
|
||||
|
||||
// Watches are the watches at work (tests set their sources before Run).
|
||||
func (d *Daemon) Watches() []*Watch {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]*Watch(nil), d.watches...)
|
||||
}
|
||||
|
||||
func (d *Daemon) config() *config.Config {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.cfg
|
||||
}
|
||||
|
||||
func (d *Daemon) neverBan() *NeverBan {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.never
|
||||
}
|
||||
|
||||
// Run watches until ctx ends.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
d.started = d.Now()
|
||||
d.ctx = ctx
|
||||
d.loadGroups(ctx)
|
||||
go d.never.Run(ctx)
|
||||
var wg sync.WaitGroup
|
||||
d.mu.Lock()
|
||||
d.startNever()
|
||||
for _, w := range d.watches {
|
||||
lines := make(chan source.Line, 256)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer close(lines)
|
||||
for {
|
||||
err := w.Source.Run(ctx, lines)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d.log.Error("the watch's source stopped; starting it again in 10 seconds", "watch", w.Cfg.Name, "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for l := range lines {
|
||||
d.Line(ctx, w, l)
|
||||
}
|
||||
}()
|
||||
d.log.Info("watching", "watch", w.Cfg.Name, "source", Describe(w.Cfg), "patterns", len(w.Cfg.Match), "hits", w.Cfg.Hits, "within", w.Cfg.Within.String(), "list", w.Cfg.Ban.List)
|
||||
d.start(w)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d.sender(ctx)
|
||||
}()
|
||||
if d.cfg.Socket != "" {
|
||||
if sock := d.config().Socket; sock != "" {
|
||||
if err := d.serveStatus(ctx); err != nil {
|
||||
d.log.Warn("no status socket (guard status won't work)", "socket", d.cfg.Socket, "err", err)
|
||||
d.log.Warn("no status socket (guard status won't work)", "socket", sock, "err", err)
|
||||
}
|
||||
}
|
||||
<-ctx.Done()
|
||||
wg.Wait()
|
||||
for _, w := range d.Watches() {
|
||||
<-w.done
|
||||
}
|
||||
d.savePending()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startNever looks never_ban's names up until a reload replaces it
|
||||
// (d.mu held).
|
||||
func (d *Daemon) startNever() {
|
||||
c, stop := context.WithCancel(d.ctx)
|
||||
d.neverStop = stop
|
||||
go d.never.Run(c)
|
||||
}
|
||||
|
||||
// start runs a watch: its source, and its lines through Line, until
|
||||
// stopped or Run's context ends (d.mu held).
|
||||
func (d *Daemon) start(w *Watch) {
|
||||
if w.Source == nil {
|
||||
w.Source = d.newSource(w.Cfg)
|
||||
}
|
||||
ctx, stop := context.WithCancel(d.ctx)
|
||||
w.stop, w.done = stop, make(chan struct{})
|
||||
lines := make(chan source.Line, 256)
|
||||
src := w.Source
|
||||
go func() {
|
||||
defer close(lines)
|
||||
for {
|
||||
err := src.Run(ctx, lines)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
d.log.Error("the watch's source stopped; starting it again in 10 seconds", "watch", w.Cfg.Name, "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer close(w.done)
|
||||
for l := range lines {
|
||||
d.Line(ctx, w, l)
|
||||
}
|
||||
}()
|
||||
d.log.Info("watching", "watch", w.Cfg.Name, "source", Describe(w.Cfg), "patterns", len(w.Cfg.Match), "hits", w.Cfg.Hits, "within", w.Cfg.Within.String(), "list", w.Cfg.Ban.List)
|
||||
}
|
||||
|
||||
// Describe says where a watch's lines come from.
|
||||
func Describe(w *config.Watch) string {
|
||||
switch w.Kind() {
|
||||
@@ -199,8 +266,9 @@ func Short(w *config.Watch) string {
|
||||
// bounties are counted as the list bans. A list that can't be read now
|
||||
// counts by /64, its default.
|
||||
func (d *Daemon) loadGroups(ctx context.Context) {
|
||||
for _, w := range d.watches {
|
||||
list := w.Cfg.Ban.List
|
||||
for _, w := range d.Watches() {
|
||||
cfg, _ := w.setup()
|
||||
list := cfg.Ban.List
|
||||
d.mu.Lock()
|
||||
_, done := d.groups[list]
|
||||
d.mu.Unlock()
|
||||
@@ -238,33 +306,34 @@ func (d *Daemon) group(list string) int {
|
||||
// Line handles one line of a watch.
|
||||
func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) {
|
||||
w.lines.Add(1)
|
||||
cfg, m := w.setup()
|
||||
addr := l.Addr
|
||||
if !addr.IsValid() {
|
||||
var ok bool
|
||||
if addr, ok = w.Matcher.Match(l.Text); !ok {
|
||||
if addr, ok = m.Match(l.Text); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.matches.Add(1)
|
||||
if why := d.never.Why(addr); why != "" {
|
||||
d.log.Debug("match, but never banned", "watch", w.Cfg.Name, "address", addr, "why", why)
|
||||
if why := d.neverBan().Why(addr); why != "" {
|
||||
d.log.Debug("match, but never banned", "watch", cfg.Name, "address", addr, "why", why)
|
||||
return
|
||||
}
|
||||
g := match.Group(addr, d.group(w.Cfg.Ban.List))
|
||||
g := match.Group(addr, d.group(cfg.Ban.List))
|
||||
n, reached := w.Tracker.Add(g, l.Time)
|
||||
d.log.Debug("match", "watch", w.Cfg.Name, "address", addr, "bounty", n, "hits", w.Cfg.Hits, "line", clip(l.Text, 300))
|
||||
d.log.Debug("match", "watch", cfg.Name, "address", addr, "bounty", n, "hits", cfg.Hits, "line", clip(l.Text, 300))
|
||||
if !reached {
|
||||
return
|
||||
}
|
||||
w.bans.Add(1)
|
||||
d.ban(w, addr, g, l.Text)
|
||||
d.ban(cfg, addr, g, l.Text)
|
||||
}
|
||||
|
||||
// BanTime is how long a group's next ban lasts: the watch's time,
|
||||
// times the repeat factor for each earlier ban within forget, up to the
|
||||
// most.
|
||||
func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
|
||||
r := d.cfg.Defaults.Repeat
|
||||
r := d.config().Defaults.Repeat
|
||||
base := w.Ban.Time.D()
|
||||
n := d.state.Repeats(group, d.Now().Add(-r.Forget.D()))
|
||||
t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n)))
|
||||
@@ -277,12 +346,12 @@ func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
|
||||
return t
|
||||
}
|
||||
|
||||
func (d *Daemon) ban(w *Watch, addr netip.Addr, g netip.Prefix, line string) {
|
||||
func (d *Daemon) ban(w *config.Watch, addr netip.Addr, g netip.Prefix, line string) {
|
||||
group := g.String()
|
||||
t := d.BanTime(w.Cfg, group)
|
||||
b := state.Ban{Time: d.Now(), Watch: w.Cfg.Name, List: w.Cfg.Ban.List, Address: addr.String(), Group: group,
|
||||
Protocol: strings.ToLower(w.Cfg.Ban.Protocol), Ports: strings.Join(w.Cfg.Ban.Port, ","), Seconds: int(t / time.Second), Reason: clip(line, 300)}
|
||||
d.log.Info("bounty reached; banning", "watch", w.Cfg.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
|
||||
t := d.BanTime(w, group)
|
||||
b := state.Ban{Time: d.Now(), Watch: w.Name, List: w.Ban.List, Address: addr.String(), Group: group,
|
||||
Protocol: strings.ToLower(w.Ban.Protocol), Ports: strings.Join(w.Ban.Port, ","), Seconds: int(t / time.Second), Reason: clip(line, 300)}
|
||||
d.log.Info("bounty reached; banning", "watch", w.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
|
||||
d.mu.Lock()
|
||||
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
|
||||
d.mu.Unlock()
|
||||
@@ -390,10 +459,11 @@ func (d *Daemon) savePending() {
|
||||
p = append(p, b)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
cfg := d.config()
|
||||
d.state.SetPending(p)
|
||||
d.state.Forget(d.Now().Add(-d.cfg.Defaults.Repeat.Forget.D()))
|
||||
d.state.Forget(d.Now().Add(-cfg.Defaults.Repeat.Forget.D()))
|
||||
if err := d.state.Save(); err != nil {
|
||||
d.log.Warn("saving the state failed", "file", d.cfg.State, "err", err)
|
||||
d.log.Warn("saving the state failed", "file", cfg.State, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,9 +500,10 @@ type WatchStatus struct {
|
||||
// Status reports the watches, their highest bounties and the recent bans.
|
||||
func (d *Daemon) Status() Status {
|
||||
s := Status{Started: d.started}
|
||||
for _, w := range d.watches {
|
||||
s.Watches = append(s.Watches, WatchStatus{Name: w.Cfg.Name, Source: Short(w.Cfg), List: w.Cfg.Ban.List, Hits: w.Cfg.Hits,
|
||||
Within: w.Cfg.Within.String(), Lines: w.lines.Load(), Matches: w.matches.Load(), Bans: w.bans.Load(), Top: w.Tracker.Top(10, d.Now())})
|
||||
for _, w := range d.Watches() {
|
||||
cfg, _ := w.setup()
|
||||
s.Watches = append(s.Watches, WatchStatus{Name: cfg.Name, Source: Short(cfg), List: cfg.Ban.List, Hits: cfg.Hits,
|
||||
Within: cfg.Within.String(), Lines: w.lines.Load(), Matches: w.matches.Load(), Bans: w.bans.Load(), Top: w.Tracker.Top(10, d.Now())})
|
||||
}
|
||||
s.Recent, s.Pending = d.state.Snapshot()
|
||||
if len(s.Recent) > 50 {
|
||||
@@ -444,7 +515,7 @@ func (d *Daemon) Status() Status {
|
||||
// serveStatus answers GET /status on the local socket (root and the
|
||||
// guard user only: the socket is 0660 in /run/guard).
|
||||
func (d *Daemon) serveStatus(ctx context.Context) error {
|
||||
path := d.cfg.Socket
|
||||
path := d.config().Socket
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -459,6 +530,16 @@ func (d *Daemon) serveStatus(ctx context.Context) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(d.Status())
|
||||
})
|
||||
mux.HandleFunc("POST /reload", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
res, err := d.ReloadFile()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(res)
|
||||
})
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go srv.Serve(ln)
|
||||
go func() {
|
||||
|
||||
Reference in new issue
Block a user