Files
Dash/apps/backend/internal/api/notify.go
T
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00

296 lines
8.4 KiB
Go

package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Notifications: a small transport registry. Each transport is configured via
// the settings key notify_<name> holding a JSON object of fields; a transport
// is active when its required fields are set. Multiple transports may be
// active — notify() fans out to all of them.
//
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
//
// Secrets (smtp_pass, tokens) live in settings like any other value and are
// never logged. Single-user self-hosted is the threat model; P5d auth gates
// the settings endpoint when enabled.
type notifyPayload struct {
Event string `json:"event"`
Name string `json:"name"`
Text string `json:"text"` // Slack-style
Content string `json:"content"` // Discord-style
Message string `json:"message"` // ntfy/generic
At string `json:"at"`
}
type transport struct {
name string
required []string
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
}
var transports = []transport{
{"webhook", []string{"url"}, sendWebhook},
{"slack", []string{"url"}, sendSlack},
{"discord", []string{"url"}, sendDiscord},
{"telegram", []string{"token", "chat_id"}, sendTelegram},
{"gotify", []string{"server", "token"}, sendGotify},
{"ntfy", []string{"server", "topic"}, sendNtfy},
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
}
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
// presets, or a bare string (legacy webhook) meaning {"url": s}.
func (s *Server) notifyCfg(name string) map[string]string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
var str string
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
return map[string]string{"url": str}
}
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if str, ok := v.(string); ok {
out[k] = str
}
}
return out
}
// configured reports whether all required fields are present. Partially
// configured transports count as configured so their send error surfaces.
func (t transport) configured(cfg map[string]string) bool {
if cfg == nil {
return false
}
for _, k := range t.required {
if strings.TrimSpace(cfg[k]) == "" {
return false
}
}
return true
}
// notify fans an event out to every configured transport, asynchronously.
func (s *Server) notify(event, name, msg string) {
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
At: time.Now().UTC().Format(time.RFC3339)}
for _, tr := range transports {
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
continue
}
tr, cfg := tr, cfg
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := tr.send(ctx, cfg, p); err != nil {
s.log.Warn("notify failed", zap.String("transport", tr.name),
zap.String("event", event), zap.Error(err))
}
}()
}
}
// postJSON posts a JSON body and reports non-2xx as an error.
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], p, map[string]string{
"Title": p.Text, "X-Dash-Event": p.Event,
})
}
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
}
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
}
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
if base == "" {
base = "https://api.telegram.org"
}
url := base + "/bot" + cfg["token"] + "/sendMessage"
return postJSON(ctx, url, map[string]any{
"chat_id": cfg["chat_id"], "text": p.Text,
}, nil)
}
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/message?token=" + cfg["token"]
return postJSON(ctx, url, map[string]any{
"title": p.Name, "message": p.Message, "priority": 5,
}, nil)
}
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
if err != nil {
return err
}
req.Header.Set("Title", p.Name)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
// plain auth only when a user is configured.
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
host, port := cfg["host"], cfg["port"]
addr := net.JoinHostPort(host, port)
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
}
if cfg["user"] != "" {
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
return err
}
}
if err := c.Mail(cfg["from"]); err != nil {
return err
}
for _, rcpt := range strings.Split(cfg["to"], ",") {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
cfg["from"], cfg["to"], p.Text, p.Message)
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
// posture as monitor targets (user-configured, but no file:// or friends).
func parseHTTPURL(raw string) string {
if !validURL(raw) {
return ""
}
return raw
}
// notifyTest sends a test event synchronously so the UI gets a real result.
// Optional body {"transport": "smtp"} limits the test to one transport.
func (s *Server) notifyTest(c *gin.Context) {
var in struct {
Transport string `json:"transport"`
}
_ = c.ShouldBindJSON(&in)
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
Content: "Dash test notification", Message: "Dash test notification",
At: time.Now().UTC().Format(time.RFC3339)}
results := map[string]string{}
for _, tr := range transports {
if in.Transport != "" && tr.name != in.Transport {
continue
}
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
if in.Transport != "" {
fail(c, http.StatusBadRequest, tr.name+" not configured")
return
}
continue
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
err := tr.send(ctx, cfg, p)
cancel()
if err != nil {
results[tr.name] = "error: " + err.Error()
} else {
results[tr.name] = "ok"
}
}
if len(results) == 0 {
fail(c, http.StatusBadRequest, "no notification transport configured")
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}