fix(ingestion): tripwire alerts survive outages; drop only permanent rejections
PR #160 round 2: dropping a message after 5 failures could not tell a poison message from an ntfy outage. A 15-minute outage dropped the only incident with nothing reported, and drops were not counted (74 of 300 removals were neither delivered nor counted). - audit.NtfyNotifier returns *RejectedError for non-2xx; Permanent() is true for 4xx other than 429. - The tripwire queue drops a message only on a permanent rejection and counts it; every other failure is an outage and is retried, however long. Each flush tries every queued message, so an undeliverable one cannot block the rest. - Overflow and drops go into one counter, sent as a summary retried every tick and never dropped. Every removal is delivered or counted. Tests (red first): an outage of ~100 ticks still delivers the only incident; delivered + counted equals the number of removals; a permanently rejected message is dropped and counted. The short-outage test is renamed to say it is short. The Watch doc no longer claims 'loses none' unconditionally. Refs #157 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ package symlinkguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
@@ -28,7 +29,6 @@ const DefaultInterval = 30 * time.Second
|
||||
const (
|
||||
maxPathsPerAlert = 10 // further paths are counted, not listed
|
||||
maxPending = 5 // queued alerts; the rest become one summary
|
||||
maxAttempts = 5 // a message failing this often is dropped
|
||||
)
|
||||
|
||||
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
|
||||
@@ -130,8 +130,9 @@ func Scan(brainDir string) (Result, error) {
|
||||
// hyperguild#157 decision), so one is readable for at most one interval.
|
||||
// Each removal is an alert; a persistent finding (a link it could not remove,
|
||||
// an unreadable dir, a scan error) alerts once per distinct state. Alerts
|
||||
// queue until Notify succeeds, so an outage delays them but loses none, even
|
||||
// though the next scan of a cleaned brain is clean.
|
||||
// queue until Notify succeeds (see alertQueue): an outage delays them, and
|
||||
// any that do not fit the bounded queue, or are undeliverable, are counted in
|
||||
// a summary that is never dropped, so every incident is delivered or counted.
|
||||
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
@@ -171,25 +172,28 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
|
||||
}
|
||||
}
|
||||
|
||||
// alertQueue holds alerts until Notify accepts them, bounded: at most
|
||||
// maxPending queued, further ones counted into one summary, and a message
|
||||
// that fails maxAttempts times dropped so it cannot block the rest.
|
||||
// alertQueue holds alerts until Notify accepts them. It is bounded (at most
|
||||
// maxPending queued) and loses nothing from the record: every overflow and
|
||||
// every dropped message increments suppressed, sent as a summary that is
|
||||
// retried every tick and never dropped.
|
||||
//
|
||||
// A message is dropped only when the notifier rejects it permanently (an
|
||||
// error with Permanent() == true, e.g. ntfy 413). Every other failure is an
|
||||
// outage, and nothing is dropped however long it lasts (PR #160 round 2:
|
||||
// dropping after N raw failures lost the only incident of a long outage).
|
||||
// Each flush tries every queued message, so one undeliverable message cannot
|
||||
// block the others.
|
||||
type alertQueue struct {
|
||||
items []queued
|
||||
items []string
|
||||
suppressed int
|
||||
}
|
||||
|
||||
type queued struct {
|
||||
msg string
|
||||
attempts int
|
||||
}
|
||||
|
||||
func (q *alertQueue) push(msg string) {
|
||||
if len(q.items) >= maxPending {
|
||||
q.suppressed++
|
||||
return
|
||||
}
|
||||
q.items = append(q.items, queued{msg: msg})
|
||||
q.items = append(q.items, msg)
|
||||
}
|
||||
|
||||
func (q *alertQueue) flush(ctx context.Context, n Notifier) {
|
||||
@@ -197,28 +201,34 @@ func (q *alertQueue) flush(ctx context.Context, n Notifier) {
|
||||
q.items, q.suppressed = nil, 0
|
||||
return
|
||||
}
|
||||
for len(q.items) > 0 {
|
||||
head := &q.items[0]
|
||||
if err := n.Notify(ctx, head.msg); err != nil {
|
||||
head.attempts++
|
||||
if head.attempts >= maxAttempts {
|
||||
slog.Error("symlink tripwire alert dropped after repeated failures", "attempts", head.attempts, "err", err)
|
||||
q.items = q.items[1:]
|
||||
continue
|
||||
}
|
||||
kept := q.items[:0]
|
||||
for _, msg := range q.items {
|
||||
err := n.Notify(ctx, msg)
|
||||
switch {
|
||||
case err == nil:
|
||||
case permanent(err):
|
||||
slog.Error("symlink tripwire alert rejected permanently; counted in the summary", "err", err)
|
||||
q.suppressed++
|
||||
default:
|
||||
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
|
||||
return
|
||||
kept = append(kept, msg)
|
||||
}
|
||||
q.items = q.items[1:]
|
||||
}
|
||||
q.items = kept
|
||||
if q.suppressed > 0 {
|
||||
q.items = append(q.items, queued{msg: fmt.Sprintf(
|
||||
"brain symlink tripwire (hyperguild#157): %d further alert(s) were suppressed while the notifier was failing; check the ingestion logs", q.suppressed)})
|
||||
q.suppressed = 0
|
||||
q.flush(ctx, n)
|
||||
msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): %d further alert(s) were suppressed or undeliverable; check the ingestion logs", q.suppressed)
|
||||
if err := n.Notify(ctx, msg); err == nil {
|
||||
q.suppressed = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permanent reports whether err says the message itself can never be sent.
|
||||
func permanent(err error) bool {
|
||||
var p interface{ Permanent() bool }
|
||||
return errors.As(err, &p) && p.Permanent()
|
||||
}
|
||||
|
||||
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
|
||||
// the brain dir, after re-checking it is still a symlink. Removing a symlink
|
||||
// never touches its target. It returns what was removed and what was not.
|
||||
|
||||
Reference in New Issue
Block a user