Files
meshcore-analyzer/cmd/server/user_mgmt_config.go
T
efitenandClaude Opus 5.5 0737f10a5d feat(notify): optional external node alerts feed and the node.external event (#2177) (#2178)
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>
2026-10-10 20:54:35 +02:00

317 lines
11 KiB
Go

package main
import (
"errors"
"fmt"
"net"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/meshcore-analyzer/users"
)
// UserManagementConfig is the "userManagement" block of config.json.
type UserManagementConfig struct {
Enabled bool `json:"enabled"`
DBPath string `json:"dbPath,omitempty"`
AdminEmails []string `json:"adminEmails,omitempty"`
PublicBaseURL string `json:"publicBaseUrl,omitempty"`
SessionDays int `json:"sessionDays,omitempty"`
TrustedProxies []string `json:"trustedProxies,omitempty"`
Mail UserMailConfig `json:"mail"`
ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"`
Notifications *NotificationsConfig `json:"notifications,omitempty"`
Backup *UsersBackupConfig `json:"backup,omitempty"`
}
// UserMailConfig is userManagement.mail.
type UserMailConfig struct {
Provider string `json:"provider,omitempty"`
BrevoAPIKey string `json:"brevoApiKey,omitempty"`
FromEmail string `json:"fromEmail,omitempty"`
FromName string `json:"fromName,omitempty"`
WebhookSecret string `json:"webhookSecret,omitempty"`
}
// ChannelProposalsConfig is userManagement.channelProposals
// (docs/specs/2026-10-07-channel-proposals-design.md). Off by default.
type ChannelProposalsConfig struct {
Enabled bool `json:"enabled"`
MaxPending int `json:"maxPending,omitempty"`
MaxApproved int `json:"maxApproved,omitempty"`
PerUserPerDay int `json:"perUserPerDay,omitempty"`
}
// proposalSettings is the resolved form; the zero value means off.
type proposalSettings struct {
enabled bool
maxPending int // pending proposals of every kind
maxApproved int // approved hashtag channels; bounds the ingestor's key set
perUserPerDay int
}
const (
defaultMaxPendingProposals = 100
defaultMaxApprovedChannels = 128
defaultProposalsPerUserPerDay = 5
)
func positiveOr(v, def int) int {
if v <= 0 {
return def
}
return v
}
// resolveProposals fills the defaults for absent, zero or negative limits.
func resolveProposals(c *ChannelProposalsConfig) proposalSettings {
if c == nil || !c.Enabled {
return proposalSettings{}
}
return proposalSettings{
enabled: true,
maxPending: positiveOr(c.MaxPending, defaultMaxPendingProposals),
maxApproved: positiveOr(c.MaxApproved, defaultMaxApprovedChannels),
perUserPerDay: positiveOr(c.PerUserPerDay, defaultProposalsPerUserPerDay),
}
}
// NotificationsConfig is userManagement.notifications
// (docs/specs/2026-10-07-node-notifications-design.md). Off by default.
type NotificationsConfig struct {
Enabled bool `json:"enabled"`
IntervalMinutes int `json:"intervalMinutes,omitempty"`
PerUserPerDay int `json:"perUserPerDay,omitempty"`
MaxMailsPerDay int `json:"maxMailsPerDay,omitempty"`
MaxWatchesPerUser int `json:"maxWatchesPerUser,omitempty"`
// ExternalAlerts feeds the node.external event (#2177). Absent: the
// event is not offered.
ExternalAlerts *ExternalAlertsConfig `json:"externalAlerts,omitempty"`
}
// ExternalAlertsConfig is userManagement.notifications.externalAlerts: a
// JSON feed of per-node alerts computed outside CoreScope.
type ExternalAlertsConfig struct {
URL string `json:"url"`
MaxAgeHours int `json:"maxAgeHours,omitempty"`
Label string `json:"label,omitempty"`
}
// notifySettings is the resolved form; the zero value means off.
type notifySettings struct {
enabled bool
interval time.Duration // between evaluations, at least a minute
perUserPerDay int // notification mails per user per 24 hours
maxMailsPerDay int // notification mails per instance per 24 hours
maxWatchesPerUser int
external externalAlertSettings
}
// externalAlertSettings is the resolved externalAlerts block; url "" means
// not configured.
type externalAlertSettings struct {
url string
maxAge time.Duration // a feed generated longer ago is stale
label string // names the source in mail lines and on the account page
}
const (
defaultExternalAlertsMaxAgeHours = 48
defaultExternalAlertsLabel = "External alert"
)
// resolveExternalAlerts validates the externalAlerts block and fills the
// defaults; nil gives the zero value (not configured).
func resolveExternalAlerts(c *ExternalAlertsConfig) (externalAlertSettings, error) {
if c == nil {
return externalAlertSettings{}, nil
}
u, err := url.Parse(strings.TrimSpace(c.URL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return externalAlertSettings{}, errors.New("userManagement.notifications.externalAlerts.url must be an absolute http(s) URL")
}
label := mailSafeText(c.Label)
if label == "" {
label = defaultExternalAlertsLabel
}
return externalAlertSettings{url: u.String(), label: label,
maxAge: time.Duration(positiveOr(c.MaxAgeHours, defaultExternalAlertsMaxAgeHours)) * time.Hour}, nil
}
const (
defaultNotifyIntervalMinutes = 5
defaultNotifyPerUserPerDay = 20
// defaultNotifyMaxMailsPerDay counts notification mail only. A third of
// Brevo's free 300 a day, so activation, reset and address-change mail
// (and other senders on the same account) still fit in the quota.
defaultNotifyMaxMailsPerDay = 100
defaultNotifyMaxWatches = 50
)
// resolveNotifications fills the defaults for absent, zero or negative
// values. The interval is in whole minutes, so its floor is one minute.
func resolveNotifications(c *NotificationsConfig) notifySettings {
if c == nil || !c.Enabled {
return notifySettings{}
}
return notifySettings{
enabled: true,
interval: time.Duration(positiveOr(c.IntervalMinutes, defaultNotifyIntervalMinutes)) * time.Minute,
perUserPerDay: positiveOr(c.PerUserPerDay, defaultNotifyPerUserPerDay),
maxMailsPerDay: positiveOr(c.MaxMailsPerDay, defaultNotifyMaxMailsPerDay),
maxWatchesPerUser: positiveOr(c.MaxWatchesPerUser, defaultNotifyMaxWatches),
}
}
// UsersBackupConfig is userManagement.backup: scheduled users.db
// snapshots (docs/specs/2026-10-08-account-export-and-users-backup-design.md).
// On by default: an absent block, or one without "enabled", means on.
type UsersBackupConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Dir string `json:"dir,omitempty"`
Keep int `json:"keep,omitempty"`
}
// backupSettings is the resolved form; the zero value means off.
type backupSettings struct {
enabled bool
dir string // relative paths resolve against the working directory, like dbPath
keep int // snapshots kept after each run
}
const defaultBackupKeep = 7
// resolveBackup fills the defaults: dir "backups" next to users.db, keep 7
// for an absent, zero or negative value. Only "enabled": false turns it off.
func resolveBackup(c *UsersBackupConfig, dbPath string) backupSettings {
if c != nil && c.Enabled != nil && !*c.Enabled {
return backupSettings{}
}
b := backupSettings{enabled: true, dir: filepath.Join(filepath.Dir(dbPath), "backups"), keep: defaultBackupKeep}
if c == nil {
return b
}
if d := strings.TrimSpace(c.Dir); d != "" {
b.dir = d
}
b.keep = positiveOr(c.Keep, defaultBackupKeep)
return b
}
// UserManagementEnabled reports whether optional accounts are on. Nil config
// or absent section means off (the default).
func (c *Config) UserManagementEnabled() bool {
return c != nil && c.UserManagement != nil && c.UserManagement.Enabled
}
// userMgmtSettings is the validated, resolved form the auth service runs on.
type userMgmtSettings struct {
dbPath string
adminEmails map[string]bool
baseURL *url.URL // no trailing slash, no query or fragment
secureCookie bool
sessionTTL time.Duration
trustedProxies []*net.IPNet
provider string // "brevo" or (e2etest builds only) "fake"
brevoAPIKey string
fromEmail string
fromName string
webhookSecret string
proposals proposalSettings
notify notifySettings
backup backupSettings
}
const defaultSessionDays = 30
// fakeMailerAllowed is flipped only by the e2etest build (auth_e2e.go).
var fakeMailerAllowed bool
// resolveUserManagement validates the block and fills defaults. It refuses
// configurations where nobody could activate an account, so the server
// fails at startup instead of running half-working.
func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, getenv func(string) string) (*userMgmtSettings, error) {
set := &userMgmtSettings{adminEmails: map[string]bool{}}
set.dbPath = strings.TrimSpace(u.DBPath)
if set.dbPath == "" {
set.dbPath = filepath.Join(filepath.Dir(measurementDBPath), "users.db")
}
for _, raw := range u.AdminEmails {
e, err := users.NormalizeEmail(raw)
if err != nil {
return nil, fmt.Errorf("userManagement.adminEmails: %q is not a valid address", raw)
}
set.adminEmails[e] = true
}
base, err := url.Parse(strings.TrimSpace(u.PublicBaseURL))
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
return nil, errors.New("userManagement.publicBaseUrl must be an absolute http(s) URL, e.g. https://corescope.example.org")
}
base.Path = strings.TrimRight(base.Path, "/")
base.RawQuery, base.Fragment = "", ""
set.baseURL = base
set.secureCookie = base.Scheme == "https"
days := u.SessionDays
if days <= 0 {
days = defaultSessionDays
}
if days > 365 {
days = 365
}
set.sessionTTL = time.Duration(days) * 24 * time.Hour
set.trustedProxies = parseCIDRList(u.TrustedProxies, "userManagement.trustedProxies")
set.provider = strings.ToLower(strings.TrimSpace(u.Mail.Provider))
if set.provider == "" {
set.provider = "brevo"
}
set.brevoAPIKey = envOrValue(getenv, "CORESCOPE_BREVO_API_KEY", u.Mail.BrevoAPIKey)
set.webhookSecret = envOrValue(getenv, "CORESCOPE_BREVO_WEBHOOK_SECRET", u.Mail.WebhookSecret)
from, err := users.NormalizeEmail(u.Mail.FromEmail)
if err != nil {
return nil, errors.New("userManagement.mail.fromEmail must be a valid address")
}
set.fromEmail = from
set.fromName = strings.TrimSpace(u.Mail.FromName)
if set.fromName == "" {
set.fromName = "CoreScope"
}
switch set.provider {
case "brevo":
if set.brevoAPIKey == "" {
return nil, errors.New("userManagement.mail: a Brevo API key is required (mail.brevoApiKey or CORESCOPE_BREVO_API_KEY)")
}
case "fake":
if !fakeMailerAllowed {
return nil, errors.New(`userManagement.mail.provider "fake" is only available in e2etest builds`)
}
default:
return nil, fmt.Errorf("userManagement.mail.provider %q is not supported (use \"brevo\")", u.Mail.Provider)
}
if set.webhookSecret != "" && len(set.webhookSecret) < 16 {
return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters")
}
set.proposals = resolveProposals(u.ChannelProposals)
set.notify = resolveNotifications(u.Notifications)
if set.notify.enabled {
if set.notify.external, err = resolveExternalAlerts(u.Notifications.ExternalAlerts); err != nil {
return nil, err
}
}
set.backup = resolveBackup(u.Backup, set.dbPath)
return set, nil
}
func envOrValue(getenv func(string) string, key, fallback string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return strings.TrimSpace(fallback)
}