mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 16:57:20 +00:00
Part E of #2128: a logged-in user watches nodes and gets one mail when a watched node goes offline, comes back, or reports a low battery. Admins can add instance events: a new foreign node, and an observer going offline or back. This covers the per-user mail part of #775. Stacked on #2139 (part D, which builds on #2138). Review the commits after that branch. ## The situation A sysop learns that a repeater went silent, or that its battery is running down, only by opening CoreScope. #775 asks for notifications. Accounts (part A) now give a verified address to mail and a mailer with delivery status. ## What this PR adds **Events.** They use the instance thresholds, so a mail does not disagree with the node page: - `node.offline`: silent for the role's `healthThresholds` window. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (#1598). - `node.battery`: advert telemetry below `batteryThresholds.lowMv`, recovered at `lowMv + 100`. - Admins only: `foreign.new` (once per node) and `observer.offline`. **Store.** Schema v5 with `notification_prefs`, `notification_watches` and `notification_state`. **Server** (only with `userManagement.notifications.enabled`) - A notifier next to the janitor, every `intervalMinutes` (5). The evaluator is a pure function of watches, prefs, stored states and node snapshots. - No burst after a restart or a new watch: a subject's first evaluation stores its state without mailing. The loop waits for the startup load. While ingest is stale (newest packet older than 30 minutes) the offline checks pause, and after recovery they wait one silent window. - One mail per user per check. Limits: 20 per user and 100 per instance per rolling 24 hours, 50 watches per user. A change over a limit is recorded and never mailed later. - Every mail has a one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`). The GET only redirects to a confirm page, so link scanners cannot unsubscribe anyone. Node names are cleaned of control and bidi characters before they go into a mail. - Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE /api/account/notifications/watches/{pubkey}`, `POST /api/account/notifications/watch-my-nodes` (copies the synced "my nodes" list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message` gains `Headers`. **Frontend.** A "Notify me" toggle on the node pages, a Notifications section on the account page (`#/account?section=notifications`), an unsubscribe page, and the notification mail count on the admin overview. Spec: [`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md). ## Performance Each check reads watches, prefs and states from `users.db` (one query each), the watched nodes from the analyzer DB in chunks of 500, and last-heard times from the packet store. | Measurement | Result | |---|---| | One check on our staging instance, 130,000 packets in memory, 1 watcher | 6.9 ms, of which 0.93 ms holding the store read lock | | Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes | 1.75 ms | No work is added to ingest, broadcast or any request path. ## Verification - `internal/users` and `internal/mailer` with `-race`; the `cmd/server` suite plus `-race` on the notifier tests; 74 new Go tests. The same Windows-only failure as noted in #2138 applies. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 26 of 26 steps locally. With notifications off the new steps fail, so they do test the flag. - On our staging and production instance since 7 October 2026. ## Not in this PR - Other channels (Discord, Telegram, webhooks). Detection is separate from delivery, so one can be added. - The topology, RF and anomaly alerts of #775. - If the whole server starts after a feed outage that already ended, the grace window is unknown and a watcher can get one wrong offline mail. The user guide says so. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
7.4 KiB
Go
249 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/mailer"
|
|
"github.com/meshcore-analyzer/users"
|
|
)
|
|
|
|
// authService is the optional user-management layer. Server.auth is nil
|
|
// unless userManagement.enabled, and nothing in this file runs then.
|
|
type authService struct {
|
|
st *users.Store
|
|
mail mailer.Mailer
|
|
set *userMgmtSettings
|
|
ipr *wsLimiter // only its clientIP rule is used
|
|
|
|
login *rateLimiter
|
|
signup *rateLimiter // register, forgot, self-service resend
|
|
hook *rateLimiter
|
|
settingsPut *rateLimiter // PUT /api/account/settings, per user
|
|
|
|
// approved is the snapshot of approved hashtag channel names, filled at
|
|
// construction and reloaded from users.db after every proposal decision.
|
|
// approvedMu serialises the reloads so an older read never replaces a
|
|
// newer one.
|
|
approvedMu sync.Mutex
|
|
approved atomic.Pointer[[]string]
|
|
|
|
// notify evaluates node notifications; nil unless
|
|
// userManagement.notifications.enabled.
|
|
notify *notifier
|
|
|
|
warnIndistinct sync.Once
|
|
stop chan struct{}
|
|
stopOnce sync.Once
|
|
wg sync.WaitGroup // the janitor and the notifier
|
|
auditWG sync.WaitGroup // in-flight auditAsync writes
|
|
}
|
|
|
|
func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *authService {
|
|
a := &authService{
|
|
st: st, mail: m, set: set,
|
|
ipr: &wsLimiter{trustedProxies: set.trustedProxies},
|
|
login: newRateLimiter(10, 15*time.Minute),
|
|
signup: newRateLimiter(5, time.Hour),
|
|
hook: newRateLimiter(600, time.Minute),
|
|
settingsPut: newRateLimiter(60, time.Hour),
|
|
stop: make(chan struct{}),
|
|
}
|
|
a.refreshApproved()
|
|
return a
|
|
}
|
|
|
|
// initUserManagement builds s.auth when the feature is on. Call it after
|
|
// NewServer and before RegisterRoutes. measurementDBPath is passed to
|
|
// users.Open as a forbidden path, so users.db can never be the analyzer DB.
|
|
func (s *Server) initUserManagement(measurementDBPath string) error {
|
|
if !s.cfg.UserManagementEnabled() {
|
|
return nil
|
|
}
|
|
set, err := resolveUserManagement(s.cfg.UserManagement, measurementDBPath, os.Getenv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st, err := users.Open(set.dbPath, measurementDBPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var m mailer.Mailer
|
|
if set.provider == "fake" {
|
|
m = &mailer.Fake{}
|
|
} else {
|
|
m = mailer.NewBrevo(set.brevoAPIKey, set.fromEmail, set.fromName)
|
|
}
|
|
s.auth = newAuthService(set, st, m)
|
|
s.auth.logStartup()
|
|
s.auth.wg.Add(1)
|
|
go func() {
|
|
defer s.auth.wg.Done()
|
|
s.auth.janitor(time.Hour)
|
|
}()
|
|
if set.notify.enabled {
|
|
s.auth.notify = newNotifier(s.auth, serverNotifySource{s: s}, time.Now)
|
|
s.auth.wg.Add(1)
|
|
go func() {
|
|
defer s.auth.wg.Done()
|
|
s.auth.notify.loop(s.auth.stop, set.notify.interval)
|
|
}()
|
|
log.Printf("[notify] node notifications enabled: every %s, at most %d mails per user and %d in total per 24 hours",
|
|
set.notify.interval, set.notify.perUserPerDay, set.notify.maxMailsPerDay)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closeUserManagement stops the janitor and closes users.db. Safe when off.
|
|
func (s *Server) closeUserManagement() {
|
|
if s.auth == nil {
|
|
return
|
|
}
|
|
s.auth.stopOnce.Do(func() {
|
|
close(s.auth.stop)
|
|
s.auth.wg.Wait()
|
|
s.auth.waitAudits()
|
|
if err := s.auth.st.Close(); err != nil {
|
|
log.Printf("[users] close: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (a *authService) logStartup() {
|
|
log.Printf("[users] user management enabled: db=%s, %d config admin(s), webhook=%v",
|
|
absForLog(a.set.dbPath), len(a.set.adminEmails), a.set.webhookSecret != "")
|
|
admins, err := a.st.List(users.ListFilter{Role: users.RoleAdmin})
|
|
if err != nil {
|
|
log.Printf("[users] list admins: %v", err)
|
|
return
|
|
}
|
|
for _, u := range admins {
|
|
if !a.isConfigAdmin(u.Email) {
|
|
log.Printf("[users] note: admin #%d is not in adminEmails (promoted in the UI, or removed from config)", u.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *authService) janitor(every time.Duration) {
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
a.prune()
|
|
select {
|
|
case <-a.stop:
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *authService) prune() {
|
|
if _, err := a.st.PruneStalePending(48 * time.Hour); err != nil {
|
|
log.Printf("[users] prune pending: %v", err)
|
|
}
|
|
if _, err := a.st.PruneExpiredSessions(); err != nil {
|
|
log.Printf("[users] prune sessions: %v", err)
|
|
}
|
|
if _, err := a.st.PruneTokens(7 * 24 * time.Hour); err != nil {
|
|
log.Printf("[users] prune tokens: %v", err)
|
|
}
|
|
if _, err := a.st.PruneMail(90 * 24 * time.Hour); err != nil {
|
|
log.Printf("[users] prune mail log: %v", err)
|
|
}
|
|
// Login rows only; every other audit action is kept (admin-dashboard spec).
|
|
if _, err := a.st.PruneAudit([]string{"user.login", "user.login.failed"}, 90*24*time.Hour); err != nil {
|
|
log.Printf("[users] prune login audit: %v", err)
|
|
}
|
|
if _, err := a.st.PruneProposals(proposalRetention); err != nil {
|
|
log.Printf("[users] prune proposals: %v", err)
|
|
}
|
|
a.login.gc()
|
|
a.signup.gc()
|
|
a.hook.gc()
|
|
a.settingsPut.gc()
|
|
}
|
|
|
|
func (a *authService) isConfigAdmin(email string) bool { return a.set.adminEmails[email] }
|
|
|
|
// refreshApproved reloads the approved hashtag channel names from users.db,
|
|
// oldest approval first and capped at maxApproved like the ingestor. The
|
|
// mutex spans the read and the store, so concurrent decisions cannot leave
|
|
// an older read in place. A failure is logged and keeps the previous
|
|
// snapshot. No-op with proposals off.
|
|
func (a *authService) refreshApproved() {
|
|
if !a.set.proposals.enabled {
|
|
return
|
|
}
|
|
a.approvedMu.Lock()
|
|
defer a.approvedMu.Unlock()
|
|
list, err := a.st.ApprovedSubjects(users.KindHashtagChannel, a.set.proposals.maxApproved)
|
|
if err != nil {
|
|
log.Printf("[users] load approved channels: %v", err)
|
|
return
|
|
}
|
|
a.approved.Store(&list)
|
|
}
|
|
|
|
// approvedChannels returns the current snapshot; never nil.
|
|
func (a *authService) approvedChannels() []string {
|
|
if p := a.approved.Load(); p != nil {
|
|
return *p
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
// roleFor is the role an address gets on activation.
|
|
func (a *authService) roleFor(email string) users.Role {
|
|
if a.isConfigAdmin(email) {
|
|
return users.RoleAdmin
|
|
}
|
|
return users.RoleUser
|
|
}
|
|
|
|
func (a *authService) audit(actor *int64, action string, target *int64, detail map[string]string) {
|
|
if err := a.st.Audit(actor, action, target, detail); err != nil {
|
|
log.Printf("[users] audit %s: %v", action, err)
|
|
}
|
|
}
|
|
|
|
// auditAsync writes an audit row in the background, so neither the write
|
|
// nor a locked users.db delays the response. Best-effort: a failure is
|
|
// logged by a.audit, and a row is lost if the process stops first.
|
|
func (a *authService) auditAsync(actor *int64, action string, target *int64, detail map[string]string) {
|
|
a.auditWG.Add(1)
|
|
go func() {
|
|
defer a.auditWG.Done()
|
|
a.audit(actor, action, target, detail)
|
|
}()
|
|
}
|
|
|
|
// waitAudits blocks until every auditAsync write has finished.
|
|
func (a *authService) waitAudits() { a.auditWG.Wait() }
|
|
|
|
func idPtr(id int64) *int64 { return &id }
|
|
|
|
// invalidateTokens burns uid's outstanding links of each purpose. A failure
|
|
// is logged (user id only) and returned; callers answer 500.
|
|
func (a *authService) invalidateTokens(op string, uid int64, ps ...users.Purpose) error {
|
|
for _, p := range ps {
|
|
if err := a.st.InvalidateTokens(uid, p); err != nil {
|
|
log.Printf("[users] %s: invalidate %s tokens for user #%d: %v", op, p, uid, err)
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// absForLog resolves path for a log line, so a relative users.db path shows
|
|
// which file it means; the path as given when it cannot be resolved.
|
|
func absForLog(path string) string {
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
return abs
|
|
}
|
|
return path
|
|
}
|