fix(audit): each ntfy alert source sends its own title (#163)
Every alert was titled "brain-capture audit degraded", including the symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle returns a titled copy (nil stays nil); buildNotifier(getenv, title) is called per source: capture audit (unchanged title), "brain symlink tripwire", "brain governance edit". Tests assert the Title header. Fixes #163. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,9 +179,12 @@ func alertingWarnings(getenv func(string) string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNotifier() audit.Notifier {
|
// buildNotifier returns the ntfy notifier for one alert source, titled so
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
// the source is recognisable (hyperguild#163), or nil when ntfy is not
|
||||||
return n
|
// configured.
|
||||||
|
func buildNotifier(getenv func(string) string, title string) audit.Notifier {
|
||||||
|
if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
return n.WithTitle(title)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -203,7 +206,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
|
|||||||
logger.Error("capture audit buffer init", "err", err)
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
notifier := buildNotifier()
|
notifier := buildNotifier(os.Getenv, "brain-capture audit degraded")
|
||||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
@@ -366,7 +369,7 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// hyperguild#157: report a symlink that appears in the brain while running.
|
// hyperguild#157: report a symlink that appears in the brain while running.
|
||||||
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
|
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire"))
|
||||||
|
|
||||||
// Never fatal on an unreachable issuer (#83): on a cold cluster boot this
|
// Never fatal on an unreachable issuer (#83): on a cold cluster boot this
|
||||||
// pod can beat dex to serving and get a 502 from ingress, and exiting meant
|
// pod can beat dex to serving and get a 502 from ingress, and exiting meant
|
||||||
@@ -546,7 +549,7 @@ func main() {
|
|||||||
// capture already gets, plus an out-of-band alert. Wired here because
|
// capture already gets, plus an out-of-band alert. Wired here because
|
||||||
// brain_edit_root needs the capture plumbing anyway — that is what
|
// brain_edit_root needs the capture plumbing anyway — that is what
|
||||||
// re-derives the static principal its gate checks.
|
// re-derives the static principal its gate checks.
|
||||||
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit"))
|
||||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
||||||
"governance_observer", true)
|
"governance_observer", true)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -74,3 +77,16 @@ func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "classification.yaml")
|
assert.Contains(t, err.Error(), "classification.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hyperguild#163: each alert source gets its own ntfy title.
|
||||||
|
func TestBuildNotifierUsesTheGivenTitle(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Title") }))
|
||||||
|
defer srv.Close()
|
||||||
|
env := map[string]string{"BRAIN_NTFY_URL": srv.URL}
|
||||||
|
n := buildNotifier(func(k string) string { return env[k] }, "brain symlink tripwire")
|
||||||
|
require.NotNil(t, n)
|
||||||
|
require.NoError(t, n.Notify(context.Background(), "m"))
|
||||||
|
assert.Equal(t, "brain symlink tripwire", got)
|
||||||
|
assert.Nil(t, buildNotifier(func(string) string { return "" }, "x"), "no ntfy URL, no notifier")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ import (
|
|||||||
type NtfyNotifier struct {
|
type NtfyNotifier struct {
|
||||||
topicURL string
|
topicURL string
|
||||||
token string
|
token string
|
||||||
|
title string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultNtfyTitle is the title of a notifier built without WithTitle: the
|
||||||
|
// capture audit's degraded-state alert, its first user.
|
||||||
|
const defaultNtfyTitle = "brain-capture audit degraded"
|
||||||
|
|
||||||
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
|
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
|
||||||
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
|
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
|
||||||
// protected ntfy instances; it is held here and only sent in the
|
// protected ntfy instances; it is held here and only sent in the
|
||||||
@@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
|
|||||||
return &NtfyNotifier{
|
return &NtfyNotifier{
|
||||||
topicURL: strings.TrimRight(topicURL, "/"),
|
topicURL: strings.TrimRight(topicURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
|
title: defaultNtfyTitle,
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTitle returns a copy that sends title as the ntfy Title header, so each
|
||||||
|
// alert source (the capture audit, the symlink tripwire, governance edits)
|
||||||
|
// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not
|
||||||
|
// configured) stays nil.
|
||||||
|
func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := *n
|
||||||
|
c.title = title
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
||||||
// Notify posts a message to the ntfy topic.
|
// Notify posts a message to the ntfy topic.
|
||||||
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Title", "brain-capture audit degraded")
|
req.Header.Set("Title", n.title)
|
||||||
req.Header.Set("Priority", "high")
|
req.Header.Set("Priority", "high")
|
||||||
req.Header.Set("Tags", "warning,brain")
|
req.Header.Set("Tags", "warning,brain")
|
||||||
if n.token != "" {
|
if n.token != "" {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hyperguild#163: every alert went out titled "brain-capture audit
|
||||||
|
// degraded", including the symlink tripwire's and governance alerts.
|
||||||
|
func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = append(got, r.Header.Get("Title"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
base := NewNtfyNotifier(srv.URL, "")
|
||||||
|
require.NoError(t, base.Notify(context.Background(), "m"))
|
||||||
|
require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m"))
|
||||||
|
require.NoError(t, base.Notify(context.Background(), "m"))
|
||||||
|
assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got,
|
||||||
|
"WithTitle returns a copy; the base keeps its default title")
|
||||||
|
var nilN *NtfyNotifier
|
||||||
|
assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user