spaxel/mothership/internal/api/notifications.go
jedarden b1c2218146 feat: wire anomaly detection & security mode API endpoints
AnomalyDetector initialized in main() with periodic model updates.
Anomaly events broadcast to dashboard WS as 'alert' messages via
BroadcastAlert. GET /api/anomalies?since=24h lists recent events.
POST /api/security/arm and /api/security/disarm manage security mode.
GET /api/security/status returns armed state, learning progress, and
24h anomaly count. Arm/disarm state persisted to learning_state table
and restored on restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 15:09:34 -04:00

462 lines
14 KiB
Go

// Package api provides REST API handlers for Spaxel notification channels.
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
)
// NotificationsHandler manages notification delivery channels.
// Supported channel types: ntfy, pushover, gotify, webhook, mqtt.
type NotificationsHandler struct {
mu sync.RWMutex
db *sql.DB
channels map[string]*NotificationChannel
notifyService NotifySender
}
// NotificationChannel represents a notification delivery channel configuration.
//
// Channel types and their config schemas:
//
// ntfy: {"url":"https://ntfy.sh/my-topic", "token":"tk_..."} (token optional)
// pushover: {"app_token":"aXXXXXX...","user_key":"uXXXXXX..."}
// gotify: {"url":"https://gotify.example.com","token":"Aq7mXXXX"}
// webhook: {"url":"https://example.com/hook","method":"POST","headers":{"X-Secret":"abc"}}
// mqtt: {} (uses global MQTT connection; no config needed)
type NotificationChannel struct {
Type string `json:"type"` // ntfy, pushover, gotify, webhook, mqtt
Enabled bool `json:"enabled"` // true if channel is active
Config interface{} `json:"config,omitempty"` // channel-specific configuration
}
// NotifySender is the interface for sending test notifications.
type NotifySender interface {
Send(title, body string, data map[string]interface{}) error
}
// NewNotificationsHandler creates a new notifications handler.
func NewNotificationsHandler(dbPath string) (*NotificationsHandler, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
n := &NotificationsHandler{
db: db,
channels: make(map[string]*NotificationChannel),
}
if err := n.migrate(); err != nil {
db.Close()
return nil, err
}
if err := n.load(); err != nil {
log.Printf("[WARN] Failed to load notification channels: %v", err)
}
return n, nil
}
func (n *NotificationsHandler) migrate() error {
_, err := n.db.Exec(`
CREATE TABLE IF NOT EXISTS notification_channels (
type TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
`)
return err
}
func (n *NotificationsHandler) load() error {
rows, err := n.db.Query(`SELECT type, enabled, config_json FROM notification_channels`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var nc NotificationChannel
var enabled int
var configJSON string
if err := rows.Scan(&nc.Type, &enabled, &configJSON); err != nil {
log.Printf("[WARN] Failed to scan notification channel: %v", err)
continue
}
nc.Enabled = enabled != 0
if err := json.Unmarshal([]byte(configJSON), &nc.Config); err != nil {
log.Printf("[WARN] Failed to unmarshal config for %s: %v", nc.Type, err)
// Keep as raw JSON string if unmarshaling fails
nc.Config = configJSON
}
n.channels[nc.Type] = &nc
}
return nil
}
// Close closes the database.
func (n *NotificationsHandler) Close() error {
return n.db.Close()
}
// SetNotifyService sets the notification sender for test notifications.
func (n *NotificationsHandler) SetNotifyService(ns NotifySender) {
n.mu.Lock()
defer n.mu.Unlock()
n.notifyService = ns
}
// GetChannels returns a copy of all notification channels.
func (n *NotificationsHandler) GetChannels() map[string]*NotificationChannel {
n.mu.RLock()
defer n.mu.RUnlock()
result := make(map[string]*NotificationChannel, len(n.channels))
for k, v := range n.channels {
result[k] = v
}
return result
}
// GetChannel returns a single channel by type.
func (n *NotificationsHandler) GetChannel(channelType string) (*NotificationChannel, bool) {
n.mu.RLock()
defer n.mu.RUnlock()
ch, ok := n.channels[channelType]
return ch, ok
}
// SetChannel updates or creates a notification channel.
func (n *NotificationsHandler) SetChannel(channelType string, enabled bool, config interface{}) error {
n.mu.Lock()
defer n.mu.Unlock()
// Validate config based on channel type
if err := validateChannelConfig(channelType, config); err != nil {
return err
}
configJSON, err := json.Marshal(config)
if err != nil {
return err
}
enabledInt := 0
if enabled {
enabledInt = 1
}
now := time.Now().UnixMilli()
_, err = n.db.Exec(`
INSERT INTO notification_channels (type, enabled, config_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(type) DO UPDATE SET enabled = ?, config_json = ?, updated_at = ?
`, channelType, enabledInt, string(configJSON), now,
enabledInt, string(configJSON), now)
if err != nil {
return err
}
n.channels[channelType] = &NotificationChannel{
Type: channelType,
Enabled: enabled,
Config: config,
}
return nil
}
// validateChannelConfig validates the configuration for a specific channel type.
func validateChannelConfig(channelType string, config interface{}) error {
if config == nil {
// Nil config is valid for mqtt, optional for others
return nil
}
configMap, ok := config.(map[string]interface{})
if !ok {
// Try to unmarshal as JSON
jsonBytes, err := json.Marshal(config)
if err != nil {
return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"}
}
if err := json.Unmarshal(jsonBytes, &configMap); err != nil {
return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"}
}
}
switch channelType {
case "ntfy":
// url is required, token is optional
if _, ok := configMap["url"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
}
case "pushover":
// app_token and user_key are required
if _, ok := configMap["app_token"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "app_token", Reason: "required field missing"}
}
if _, ok := configMap["user_key"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "user_key", Reason: "required field missing"}
}
case "gotify":
// url and token are required
if _, ok := configMap["url"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
}
if _, ok := configMap["token"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "token", Reason: "required field missing"}
}
case "webhook":
// url is required, method and headers are optional
if _, ok := configMap["url"]; !ok {
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
}
// Validate method if provided
if method, ok := configMap["method"].(string); ok {
if method != "GET" && method != "POST" {
return &ChannelValidationError{Type: channelType, Field: "method", Reason: "must be GET or POST"}
}
}
case "mqtt":
// No config required for mqtt (uses global connection)
default:
return &ChannelValidationError{Type: channelType, Reason: "unknown channel type"}
}
return nil
}
// ChannelValidationError represents a configuration validation error.
type ChannelValidationError struct {
Type string
Field string
Reason string
}
func (e *ChannelValidationError) Error() string {
if e.Field != "" {
return e.Type + "." + e.Field + ": " + e.Reason
}
return e.Type + ": " + e.Reason
}
// RegisterRoutes registers notification endpoints.
//
// Notification Channels Endpoints:
//
// The notification channels API manages delivery channels for alerts and notifications.
// Supported channel types: ntfy, pushover, gotify, webhook, mqtt.
//
// GET /api/notifications/config
//
// @Summary Get notification channel configuration
// @Description Returns all notification channel configurations including enabled status and channel-specific settings.
// @Tags notifications
// @Produce json
// @Success 200 {object} notificationConfigResponse "Channel configurations"
// @Router /api/notifications/config [get]
//
// POST /api/notifications/config
//
// @Summary Update notification channel configuration
// @Description Updates one or more notification channel configurations. Each channel has type-specific required fields.
// @Description <br>ntfy: requires "url", optional "token"<br>
// @Description pushover: requires "app_token", "user_key"<br>
// @Description gotify: requires "url", "token"<br>
// @Description webhook: requires "url", optional "method" (GET/POST), optional "headers"<br>
// @Description mqtt: no config required (uses global MQTT connection)
// @Tags notifications
// @Accept json
// @Produce json
// @Param request body setNotificationConfigRequest true "Channel configurations to update"
// @Success 200 {object} notificationConfigResponse "Updated channel configurations"
// @Failure 400 {object} map[string]string "Invalid request body or validation error"
// @Failure 500 {object} map[string]string "Failed to save configuration"
// @Router /api/notifications/config [post]
//
// POST /api/notifications/test
//
// @Summary Send a test notification
// @Description Sends a test notification via the specified channel type. The channel must be enabled.
// @Tags notifications
// @Accept json
// @Produce json
// @Param request body testNotificationRequest true "Test notification parameters"
// @Success 200 {object} map[string]interface{} "Test result"
// @Failure 400 {object} map[string]string "Invalid request or no enabled channel"
// @Failure 500 {object} map[string]string "Failed to send notification"
// @Router /api/notifications/test [post]
func (n *NotificationsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/notifications/config", n.handleGetConfig)
r.Post("/api/notifications/config", n.handleSetConfig)
r.Post("/api/notifications/test", n.handleSendTest)
}
// notificationConfigResponse is the response for channel configuration requests.
type notificationConfigResponse struct {
Channels map[string]*NotificationChannel `json:"channels"`
}
// handleGetConfig handles GET /api/notifications/config requests.
func (n *NotificationsHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, notificationConfigResponse{
Channels: n.GetChannels(),
})
}
// setNotificationConfigRequest is the request body for setting channel configuration.
type setNotificationConfigRequest struct {
Channels map[string]struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Config interface{} `json:"config,omitempty"`
} `json:"channels"`
}
// handleSetConfig handles POST /api/notifications/config requests.
func (n *NotificationsHandler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
var req setNotificationConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Validate and update each channel
for channelType, ch := range req.Channels {
if ch.Type == "" {
ch.Type = channelType // Use map key as type if not specified
}
if ch.Type != channelType {
writeJSONError(w, http.StatusBadRequest, "channel type mismatch: key is "+channelType+" but body specifies "+ch.Type)
return
}
if err := n.SetChannel(ch.Type, ch.Enabled, ch.Config); err != nil {
if ce, ok := err.(*ChannelValidationError); ok {
writeJSONError(w, http.StatusBadRequest, ce.Error())
} else {
writeJSONError(w, http.StatusInternalServerError, "failed to save configuration: "+err.Error())
}
return
}
}
n.handleGetConfig(w, r)
}
// testNotificationRequest is the request body for sending a test notification.
type testNotificationRequest struct {
ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt
Title string `json:"title"` // Custom title (optional)
Body string `json:"body"` // Custom body (optional)
Data map[string]interface{} `json:"data,omitempty"` // Additional data (optional)
}
// testNotificationResponse is the response for a test notification.
type testNotificationResponse struct {
Status string `json:"status"` // "sent" or "simulated"
Message string `json:"message"` // Human-readable result
}
// handleSendTest handles POST /api/notifications/test requests.
func (n *NotificationsHandler) handleSendTest(w http.ResponseWriter, r *http.Request) {
var req testNotificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Set defaults
if req.Title == "" {
req.Title = "Spaxel Test Notification"
}
if req.Body == "" {
req.Body = "This is a test notification from Spaxel."
}
if req.Data == nil {
req.Data = make(map[string]interface{})
}
req.Data["test"] = true
// Check if channel type exists and is enabled
ch, ok := n.GetChannel(req.ChannelType)
if !ok {
writeJSONError(w, http.StatusBadRequest, "unknown channel type: "+req.ChannelType)
return
}
if !ch.Enabled {
writeJSONError(w, http.StatusBadRequest, "channel is not enabled: "+req.ChannelType)
return
}
// Send test notification
n.mu.RLock()
sender := n.notifyService
n.mu.RUnlock()
if sender == nil {
writeJSON(w, http.StatusOK, testNotificationResponse{
Status: "simulated",
Message: "Test notification simulated (no sender attached)",
})
return
}
if err := sender.Send(req.Title, req.Body, req.Data); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to send notification: "+err.Error())
return
}
writeJSON(w, http.StatusOK, testNotificationResponse{
Status: "sent",
Message: "Test notification sent successfully",
})
}
// ── Notification sending (called by automation engine) ────────────────────────────
// SendNotification sends a notification via all enabled channels.
func (n *NotificationsHandler) SendNotification(title, body string, data map[string]interface{}) error {
n.mu.RLock()
sender := n.notifyService
channels := make([]NotificationChannel, 0, len(n.channels))
for _, ch := range n.channels {
if ch.Enabled {
channels = append(channels, *ch)
}
}
n.mu.RUnlock()
if len(channels) == 0 {
return nil
}
if sender == nil {
log.Printf("[INFO] No notification sender attached, skipping: %s", title)
return nil
}
return sender.Send(title, body, data)
}