mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 10:57:17 +00:00
Closes #2177. ## What Node notifications can mail alerts computed outside CoreScope. With `userManagement.notifications.externalAlerts` configured, users can choose a new event, `node.external`, which is off by default and only offered when configured: ```json "notifications": { "enabled": true, "externalAlerts": { "url": "http://coverage-job/alerts.json", "maxAgeHours": 48, "label": "Coverage" } } ``` The feed: ```json { "generatedAt": "2026-10-11T01:35:12Z", "alerts": [ { "pubkey": "<64 hex>", "key": "SE", "text": "never heard to the south-east", "url": "https://example.org/coverage" } ] } ``` Mail lines: `<name>: Coverage: never heard to the south-east, <time>` when an alert appears, `<name>: Coverage resolved (SE), <time>` when it is gone from a fresh feed. ## How - `cmd/server/notify_external.go`: feed types, `parseExternalFeed` (RFC 3339 `generatedAt`, age check, per-alert validation), `fetchExternalFeed` (200 only, 1 MB cap, 10 s timeout). - `notify_eval.go`: `evalExternal`. Subjects are `<pubkey>/<key>`; a `<pubkey>/*` row is the per-node baseline, so the first fresh feed after watching stores the current alerts without a mail (like `foreign.new`), and later alerts mail at once. `External == nil` (not configured, nobody chose it, or stale) leaves every `node.external` state as it is. - `notify_service.go`: the feed is fetched only when configured and at least one user chose the event. Each new reason the feed is unusable is logged once, and so is the recovery. Mail text and link: the alert's `url`, otherwise the node page. - `notify_handlers.go`: `availableEvents` and the new `externalLabel` include the event only when configured; PUT answers 400 for `node.external` otherwise. - `internal/users/notify.go`: event constant, `OptionalNodeNotifyEvents` (canonical order: node, optional, admin), `RemoveWatch` also deletes the node's `<pubkey>/...` rows. - Account page label, OpenAPI descriptions, `config.example.json` comment, `docs/user-guide/accounts.md` section. Decisions on the three open questions in #2177: the generic name `node.external`; the resolved mail names the key instead of repeating the text, so there is **no `users.db` schema change** (a bump would also make older binaries refuse the file); one feed. ## Invariants - Read/write separation (#1283): the server only reads the feed over HTTP. State rows go to `users.db` through `internal/users`, like the other events. - A dead, garbled or old feed never mails "resolved": `TestEvaluateExternalStaleFeedChangesNothing` and `TestNotifierMailsExternalAlertsAndSkipsAStaleFeed` (HTTP 502 and a 72 h old feed in a row, then a fresh empty feed resolves). ## Performance The evaluator adds one pass over the stored states (already read every tick) plus, per user with the event on and per watched node, the node's current alerts. Bounded by `maxWatchesPerUser` (50) times the alerts per node. The feed is fetched once per tick (default 5 minutes) only when someone chose the event, capped at 1 MB. No packet-store lock is taken for it. Nothing changes for instances without `externalAlerts`. ## Tests - `cmd/server`: `go test` green locally (Windows; `TestSaveGeoFilterPreservesFileMode` skipped as it needs Unix mode bits). New: `notify_external_test.go` (feed parsing and limits, HTTP fetch 200/404/oversized, config resolution, evaluator baseline/new/resolved/returning/stale/not chosen/notifications off, notifier end to end with a fake feed, account routes). - `internal/users`: green, 2 new tests (optional event and canonical order, `RemoveWatch` deletes external subjects only for that node). - Frontend: `test-all.sh` green, 1 new test in `test-notifications-ui.js` (label from `externalLabel`, escaped). - `scripts/check-xss-sinks.sh --file public/notifications.js`: clean. ## Not done - No E2E test: the account page change is one label, covered by the unit test. - Not run against a live instance yet. The first feed in use is the coverage job of on8ar.eu (`alerts.json`, 29 alerts at the moment). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
13 KiB
Go
413 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/mailer"
|
|
"github.com/meshcore-analyzer/users"
|
|
)
|
|
|
|
// notifier evaluates node notifications on a timer and mails the changes
|
|
// (docs/specs/2026-10-07-node-notifications-design.md). authService.notify
|
|
// is nil unless userManagement.notifications.enabled.
|
|
type notifier struct {
|
|
a *authService
|
|
src notifySource
|
|
now func() time.Time
|
|
// ingestStale is the last tick's verdict, so the pause and the resume
|
|
// are each logged once; ingestResumedAt is when the last stale period
|
|
// ended (the offline grace, see notifyInput). In memory only: a restart
|
|
// inside the grace loses it. Only the loop goroutine touches them.
|
|
ingestStale bool
|
|
ingestResumedAt time.Time
|
|
// externalErr is the last tick's reason the externalAlerts feed was not
|
|
// used ("" when it was), so each new reason is logged once.
|
|
externalErr string
|
|
}
|
|
|
|
func newNotifier(a *authService, src notifySource, now func() time.Time) *notifier {
|
|
return ¬ifier{a: a, src: src, now: now}
|
|
}
|
|
|
|
// notifySendTimeout bounds one mail send inside a tick.
|
|
const notifySendTimeout = 30 * time.Second
|
|
|
|
// notifyIngestStaleAfter: when the newest packet in the store is older,
|
|
// offline checks pause (an MQTT or ingestor outage would otherwise mail
|
|
// every watcher "offline" and later "back online").
|
|
const notifyIngestStaleAfter = 30 * time.Minute
|
|
|
|
// checkIngest reports whether ingest is stale at now and logs when that
|
|
// verdict changes.
|
|
func (n *notifier) checkIngest(now time.Time) bool {
|
|
newest := n.src.newestPacket()
|
|
stale := newest.IsZero() || now.Sub(newest) > notifyIngestStaleAfter
|
|
switch {
|
|
case stale && !n.ingestStale:
|
|
since := "(no packets)"
|
|
if !newest.IsZero() {
|
|
since = newest.UTC().Format(time.RFC3339)
|
|
}
|
|
log.Printf("[notify] ingest stale since %s; offline checks paused", since)
|
|
case !stale && n.ingestStale:
|
|
n.ingestResumedAt = now
|
|
log.Printf("[notify] ingest fresh again; offline checks resumed")
|
|
}
|
|
n.ingestStale = stale
|
|
return stale
|
|
}
|
|
|
|
// loop evaluates every interval until stop closes. The first evaluation
|
|
// runs one interval after startup. A tick in flight sees its context
|
|
// cancelled when stop closes, so a slow mail provider does not hold up
|
|
// the shutdown.
|
|
func (n *notifier) loop(stop <-chan struct{}, every time.Duration) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() {
|
|
select {
|
|
case <-stop:
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-t.C:
|
|
n.safeTick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// safeTick runs one evaluation; a panic is logged and the next tick runs.
|
|
func (n *notifier) safeTick(ctx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[notify] evaluation panicked, the next one runs as planned: %v", r)
|
|
}
|
|
}()
|
|
n.tick(ctx)
|
|
}
|
|
|
|
// tick reads all preferences, watches and states (one query each), the
|
|
// accounts and the analyzer snapshot they need, evaluates, deletes the
|
|
// dropped states and writes the new ones, and then mails each user's
|
|
// changes. Users without a preferences row and users with notifications
|
|
// off are evaluated too (the evaluator gives the first the defaults and
|
|
// keeps the second's states current without changes). A failed read or
|
|
// state write ends the tick without mail; the next tick starts over. A
|
|
// tick that evaluates logs one line with its counts, its duration and how
|
|
// long LastHeardMap held the packet store's read lock.
|
|
func (n *notifier) tick(ctx context.Context) {
|
|
if !n.src.ready() {
|
|
return
|
|
}
|
|
started := time.Now()
|
|
now := n.now()
|
|
stale := n.checkIngest(now)
|
|
st := n.a.st
|
|
prefs, err := st.AllNotifyPrefs()
|
|
if err != nil {
|
|
log.Printf("[notify] read preferences: %v", err)
|
|
return
|
|
}
|
|
stored, err := st.AllWatches()
|
|
if err != nil {
|
|
log.Printf("[notify] read watches: %v", err)
|
|
return
|
|
}
|
|
if len(prefs) == 0 && len(stored) == 0 {
|
|
return
|
|
}
|
|
states, err := st.AllNotifyStates()
|
|
if err != nil {
|
|
log.Printf("[notify] read states: %v", err)
|
|
return
|
|
}
|
|
// The analyzer snapshot is keyed by lowercase pubkey; normalise the
|
|
// watches once so every lookup and state subject uses that form.
|
|
watches := make([]users.NotifyWatch, 0, len(stored))
|
|
watchSeen := make(map[users.NotifyKey]bool, len(stored))
|
|
seen := map[string]bool{}
|
|
var pubkeys []string
|
|
for _, w := range stored {
|
|
w.Pubkey = strings.ToLower(w.Pubkey)
|
|
k := users.NotifyKey{UserID: w.UserID, Subject: w.Pubkey}
|
|
if watchSeen[k] {
|
|
continue
|
|
}
|
|
watchSeen[k] = true
|
|
watches = append(watches, w)
|
|
if !seen[w.Pubkey] {
|
|
seen[w.Pubkey] = true
|
|
pubkeys = append(pubkeys, w.Pubkey)
|
|
}
|
|
}
|
|
byUser := make(map[int64]users.NotifyPrefs, len(prefs))
|
|
var ids []int64
|
|
for _, p := range prefs {
|
|
byUser[p.UserID] = p
|
|
ids = append(ids, p.UserID)
|
|
}
|
|
noPrefs := map[int64]bool{}
|
|
for _, w := range watches {
|
|
if _, ok := byUser[w.UserID]; !ok && !noPrefs[w.UserID] {
|
|
noPrefs[w.UserID] = true
|
|
ids = append(ids, w.UserID)
|
|
}
|
|
}
|
|
accounts, err := st.UsersByID(ids)
|
|
if err != nil {
|
|
log.Printf("[notify] read accounts: %v", err)
|
|
return
|
|
}
|
|
wantExternal := false
|
|
if n.a.set.notify.external.url != "" {
|
|
for _, p := range prefs {
|
|
wantExternal = wantExternal || p.Has(users.NotifyNodeExternal)
|
|
}
|
|
}
|
|
wantForeign, wantObservers := false, false
|
|
for _, p := range prefs {
|
|
if accounts[p.UserID].Role != users.RoleAdmin {
|
|
continue
|
|
}
|
|
wantForeign = wantForeign || p.Has(users.NotifyForeignNew)
|
|
wantObservers = wantObservers || p.Has(users.NotifyObserverOffline)
|
|
}
|
|
nodes, err := n.src.nodes(pubkeys, wantForeign)
|
|
if err != nil {
|
|
log.Printf("[notify] read nodes: %v", err)
|
|
return
|
|
}
|
|
var infra []string
|
|
for _, pk := range pubkeys {
|
|
if nd, ok := nodes[pk]; ok && notifyInfra(nd.Role) {
|
|
infra = append(infra, pk)
|
|
}
|
|
}
|
|
var observers []notifyObserver
|
|
if wantObservers {
|
|
if observers, err = n.src.observers(); err != nil {
|
|
log.Printf("[notify] read observers: %v", err)
|
|
return
|
|
}
|
|
}
|
|
var external *externalFeed
|
|
if wantExternal {
|
|
external = n.readExternal(now)
|
|
}
|
|
heard, lock := n.src.lastHeard(pubkeys)
|
|
res := evaluateNotifications(notifyInput{Now: now, Health: n.src.health(), LowMv: n.src.lowBatteryMv(),
|
|
Accounts: accounts, Prefs: prefs, Watches: watches, States: states, Nodes: nodes,
|
|
Heard: heard, Relayed: n.src.lastRelayed(infra), Observers: observers, IngestStale: stale, IngestResumedAt: n.ingestResumedAt, External: external})
|
|
if err := st.DeleteNotifyStates(res.Drop); err != nil {
|
|
log.Printf("[notify] delete dropped states, nothing mailed this time: %v", err)
|
|
return
|
|
}
|
|
if len(res.States) > 0 {
|
|
if err := st.WriteNotifyStates(res.States); err != nil {
|
|
log.Printf("[notify] write states, nothing mailed this time: %v", err)
|
|
return
|
|
}
|
|
}
|
|
mails := n.deliver(ctx, now, res.Changes, accounts, byUser)
|
|
changes := 0
|
|
for _, list := range res.Changes {
|
|
changes += len(list)
|
|
}
|
|
log.Printf("[notify] tick: users=%d changes=%d mails=%d took=%.2fms lock=%.2fms",
|
|
len(accounts), changes, mails, msFloat(time.Since(started)), msFloat(lock))
|
|
}
|
|
|
|
// readExternal fetches and validates the externalAlerts feed. It returns
|
|
// nil when the feed cannot be used (fetch failed, malformed, too old), so
|
|
// no node.external state changes; a dead feed never mails "resolved". The
|
|
// reason is logged when it changes, and once when the feed is usable again.
|
|
func (n *notifier) readExternal(now time.Time) *externalFeed {
|
|
body, err := n.src.externalFeed()
|
|
var f *externalFeed
|
|
if err == nil {
|
|
f, err = parseExternalFeed(body, now, n.a.set.notify.external.maxAge)
|
|
}
|
|
why := ""
|
|
if err != nil {
|
|
why = err.Error()
|
|
}
|
|
switch {
|
|
case why != "" && why != n.externalErr:
|
|
log.Printf("[notify] external alerts feed not used, node.external unchanged: %s", why)
|
|
case why == "" && n.externalErr != "":
|
|
log.Printf("[notify] external alerts feed usable again")
|
|
}
|
|
n.externalErr = why
|
|
if f != nil && f.Skipped > 0 {
|
|
log.Printf("[notify] external alerts feed: %d malformed alert(s) skipped", f.Skipped)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func msFloat(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
|
|
|
|
// notifySkipReason says why a user's changes are not mailed; "" means send.
|
|
// Skipped changes are already stored and are never mailed later.
|
|
func notifySkipReason(u users.User, p users.NotifyPrefs, userMails, totalMails int, ns notifySettings) string {
|
|
switch {
|
|
case u.Status != users.StatusActive:
|
|
return "account not active"
|
|
case u.EmailBouncing:
|
|
return "address bounces"
|
|
case !p.Enabled:
|
|
return "notifications off"
|
|
case userMails >= ns.perUserPerDay:
|
|
return "daily limit for this user reached"
|
|
case totalMails >= ns.maxMailsPerDay:
|
|
return "daily limit for this instance reached"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// deliver sends one mail per user with changes, within the limits counted
|
|
// from mail_log over the last 24 hours (this tick's mails included). A user
|
|
// with changes but no preferences row was evaluated with the defaults; the
|
|
// row (and so the unsubscribe token) is created before the first mail.
|
|
// Once ctx is cancelled (shutdown) the remaining users are not mailed;
|
|
// their changes are already stored, like any other skipped change. It
|
|
// returns the number of mails sent.
|
|
func (n *notifier) deliver(ctx context.Context, now time.Time, changes map[int64][]notifyChange,
|
|
accounts map[int64]users.User, prefs map[int64]users.NotifyPrefs) int {
|
|
if len(changes) == 0 {
|
|
return 0
|
|
}
|
|
total, perUser, err := n.a.st.NotifyMailCounts(now.Add(-24 * time.Hour))
|
|
if err != nil {
|
|
log.Printf("[notify] count mails, nothing mailed this time: %v", err)
|
|
return 0
|
|
}
|
|
sent := 0
|
|
ids := make([]int64, 0, len(changes))
|
|
for uid := range changes {
|
|
ids = append(ids, uid)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for i, uid := range ids {
|
|
if ctx.Err() != nil {
|
|
log.Printf("[notify] shutting down: %d user(s) with changes not mailed", len(ids)-i)
|
|
return sent
|
|
}
|
|
u := accounts[uid]
|
|
p, ok := prefs[uid]
|
|
if !ok {
|
|
p = users.DefaultNotifyPrefs(uid)
|
|
}
|
|
if why := notifySkipReason(u, p, perUser[uid], total, n.a.set.notify); why != "" {
|
|
log.Printf("[notify] user #%d: %d change(s) not mailed: %s", uid, len(changes[uid]), why)
|
|
continue
|
|
}
|
|
if !ok {
|
|
if p, err = n.a.st.NotifyPrefsFor(uid); err != nil {
|
|
log.Printf("[notify] user #%d: create preferences, %d change(s) not mailed: %v", uid, len(changes[uid]), err)
|
|
continue
|
|
}
|
|
}
|
|
sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout)
|
|
err := n.a.sendMail(sendCtx, &u, users.NotifyMailPurpose, n.a.notifyMail(u, p, changes[uid]))
|
|
cancel()
|
|
if err != nil {
|
|
continue // logged by sendMail; the changes stay recorded
|
|
}
|
|
total++
|
|
perUser[uid]++
|
|
sent++
|
|
}
|
|
return sent
|
|
}
|
|
|
|
// notifyMail is the one mail for all of a user's changes in one evaluation.
|
|
func (a *authService) notifyMail(u users.User, p users.NotifyPrefs, changes []notifyChange) mailer.Message {
|
|
base := a.set.baseURL.String()
|
|
lines := make([]mailLine, 0, len(changes))
|
|
for _, c := range changes {
|
|
lines = append(lines, mailLine{text: notifyChangeText(c, a.set.notify.external.label), url: notifySubjectURL(base, c)})
|
|
}
|
|
subject := fmt.Sprintf("%d changes on your watched nodes", len(changes))
|
|
if len(changes) == 1 {
|
|
subject = "1 change on your watched nodes"
|
|
}
|
|
msg := a.render(u.Email, u.DisplayName, "notify", mailContent{
|
|
subject: subject, greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Changes since the last check:"},
|
|
lines: lines,
|
|
actionLabel: "Manage notifications", actionURL: base + "/#/account?section=notifications",
|
|
footer: &mailLine{text: "You get this mail because you asked " + a.set.baseURL.Host +
|
|
" to watch these nodes. Turn all notification mails off with one click:", url: a.link("unsubscribe", p.UnsubToken)},
|
|
})
|
|
msg.Headers = map[string]string{
|
|
"List-Unsubscribe": "<" + base + "/api/notifications/unsubscribe?token=" + url.QueryEscape(p.UnsubToken) + ">",
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// notifyChangeText is one line of the mail: name, what happened, when (UTC).
|
|
// label names the externalAlerts source.
|
|
func notifyChangeText(c notifyChange, label string) string {
|
|
bad := c.To == users.NotifyBad
|
|
var what string
|
|
switch c.Event {
|
|
case users.NotifyNodeOffline:
|
|
what = "back online"
|
|
if bad {
|
|
what = "offline"
|
|
}
|
|
case users.NotifyNodeBattery:
|
|
what = "battery recovered"
|
|
if bad {
|
|
what = "battery low"
|
|
}
|
|
if c.BatteryMv != nil {
|
|
what += fmt.Sprintf(" (%d mV)", *c.BatteryMv)
|
|
}
|
|
case users.NotifyNodeExternal:
|
|
_, key, _ := strings.Cut(c.Subject, "/")
|
|
what = label + " resolved (" + key + ")"
|
|
if bad {
|
|
what = label + ": " + c.Text
|
|
}
|
|
case users.NotifyForeignNew:
|
|
what = "new foreign node"
|
|
case users.NotifyObserverOffline:
|
|
what = "observer back online"
|
|
if bad {
|
|
what = "observer offline"
|
|
}
|
|
}
|
|
return c.Name + ": " + what + ", " + c.At.UTC().Format("2006-01-02 15:04 UTC")
|
|
}
|
|
|
|
// notifySubjectURL links a change to its node or observer page, or an
|
|
// external alert to the link the feed gave it.
|
|
func notifySubjectURL(base string, c notifyChange) string {
|
|
switch c.Event {
|
|
case users.NotifyObserverOffline:
|
|
return base + "/#/observers/" + url.PathEscape(c.Subject)
|
|
case users.NotifyNodeExternal:
|
|
if c.URL != "" {
|
|
return c.URL
|
|
}
|
|
pk, _, _ := strings.Cut(c.Subject, "/")
|
|
return base + "/#/nodes/" + url.PathEscape(pk)
|
|
}
|
|
return base + "/#/nodes/" + url.PathEscape(c.Subject)
|
|
}
|