mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
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>
This commit is contained in:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -3,19 +3,29 @@ 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: one outbound webhook, configured via the notify_webhook
|
||||
// settings key. Payload carries event/name/text/content/message so the same
|
||||
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
|
||||
// receivers. SMTP and per-service channels are deliberately out of scope —
|
||||
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
|
||||
// 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"`
|
||||
@@ -26,54 +36,213 @@ type notifyPayload struct {
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
func (s *Server) webhookURL() string {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
// Settings values are stored as JSON — unwrap the quoted string.
|
||||
var u string
|
||||
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
type transport struct {
|
||||
name string
|
||||
required []string
|
||||
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
|
||||
}
|
||||
|
||||
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
|
||||
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) {
|
||||
url := s.webhookURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if u := parseHTTPURL(url); u == "" {
|
||||
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
|
||||
return
|
||||
}
|
||||
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
|
||||
At: time.Now().UTC().Format(time.RFC3339)}
|
||||
go s.deliver(url, p)
|
||||
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))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deliver(url string, p notifyPayload) {
|
||||
body, _ := json.Marshal(p)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// 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
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Title", p.Text) // ntfy reads the title header
|
||||
req.Header.Set("X-Dash-Event", p.Event)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
|
||||
return
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
|
||||
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
|
||||
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// notifyTest lets the settings UI verify the configured webhook.
|
||||
// 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) {
|
||||
if s.webhookURL() == "" {
|
||||
fail(c, http.StatusBadRequest, "notify_webhook not configured")
|
||||
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
|
||||
}
|
||||
s.notify("test", "dash", "Dash test notification")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
Reference in new issue
Block a user