diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go index 5463ffe..9d726fd 100644 --- a/apps/backend/internal/api/api.go +++ b/apps/backend/internal/api/api.go @@ -90,6 +90,7 @@ func (s *Server) routes() *gin.Engine { v1.DELETE("/domains/:id", s.deleteDomain) v1.POST("/domains/:id/refresh", s.refreshDomainH) v1.GET("/domains/:id/checks", s.domainChecks) + v1.POST("/notify/test", s.notifyTest) v1.GET("/status", s.getStatus) v1.GET("/widgets/types", s.widgetTypes) diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go index fb664de..e3d6a68 100644 --- a/apps/backend/internal/api/api_test.go +++ b/apps/backend/internal/api/api_test.go @@ -476,6 +476,69 @@ func TestDomainWidget(t *testing.T) { } } +func TestNotifyWebhook(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Receiver captures the webhook payload. + got := make(chan map[string]any, 1) + hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + got <- body + w.WriteHeader(http.StatusOK) + })) + defer hook.Close() + + // Unconfigured -> 400. + res, _ := do(t, "POST", srv.URL+"/api/notify/test", "") + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 without webhook, got %d", res.StatusCode) + } + + // Configure + test. + if res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`); res.StatusCode != http.StatusOK { + t.Fatalf("put settings: %d", res.StatusCode) + } + res, _ = do(t, "POST", srv.URL+"/api/notify/test", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("notify test: %d", res.StatusCode) + } + select { + case body := <-got: + if body["event"] != "test" || body["text"] == "" { + t.Fatalf("bad payload: %v", body) + } + case <-time.After(3 * time.Second): + t.Fatal("webhook never received the test notification") + } + + // Monitor transition fires monitor.down through the same webhook. + res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Flip","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create: %v", mv) + } + id := mv["id"].(string) + // pending -> down (no fire), then up -> down needs an up first. + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + defer up.Close() + res, _ = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`) + if res.StatusCode != http.StatusOK { + t.Fatalf("patch: %d", res.StatusCode) + } + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up + do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1","timeoutS":1}`) + do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> down, fires webhook + select { + case body := <-got: + if body["event"] != "monitor.down" { + t.Fatalf("expected monitor.down, got %v", body) + } + case <-time.After(3 * time.Second): + t.Fatal("webhook never received monitor.down") + } +} + func TestMidpoint(t *testing.T) { f := func(v float64) *float64 { return &v } if got := midpoint(nil, nil); got != 1024 { diff --git a/apps/backend/internal/api/domains.go b/apps/backend/internal/api/domains.go index bd9f172..7fe17a0 100644 --- a/apps/backend/internal/api/domains.go +++ b/apps/backend/internal/api/domains.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "net/http" "strconv" "time" @@ -318,16 +319,36 @@ func (s *Server) deleteDomain(c *gin.Context) { c.Status(http.StatusNoContent) } -// refreshDomain re-runs the full lookup and writes a snapshot row. +// refreshDomain re-runs the full lookup and writes a snapshot row. Expiry +// threshold crossings fire notify events on the edge (only when a state +// newly becomes true, not on every refresh). func (s *Server) refreshDomain(id string) { row, err := s.loadDomain(id) if err != nil { return } + prev := viewOf(row) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() r := s.lookup(ctx, row.Name) s.applyResult(id, r) + + next, err := s.loadDomain(id) + if err != nil { + return + } + nv := viewOf(next) + switch { + case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0): + s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name)) + case nv.Expiring && !prev.Expiring: + s.notify("domain.expiring", next.Name, + fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry)) + } + if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil { + s.notify("domain.sslExpiring", next.Name, + fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry)) + } } func (s *Server) refreshDomainH(c *gin.Context) { diff --git a/apps/backend/internal/api/monitors.go b/apps/backend/internal/api/monitors.go index a4d0843..6e0ca50 100644 --- a/apps/backend/internal/api/monitors.go +++ b/apps/backend/internal/api/monitors.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "net/http" "strconv" "time" @@ -491,10 +492,17 @@ func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) { } } -// onMonitorTransition is the alert seam — M2 wires notifications here. +// onMonitorTransition fires on real state flips (pending excluded upstream) +// — the notification seam for up/down events. func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) { s.log.Info("monitor transition", zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next)) + target := m.URL + if target == "" { + target = m.Hostname + } + s.notify("monitor."+next, m.Name, + fmt.Sprintf("Monitor %q is %s (%s)", m.Name, next, target)) } func truncate(s string, n int) string { diff --git a/apps/backend/internal/api/notify.go b/apps/backend/internal/api/notify.go new file mode 100644 index 0000000..3e7d329 --- /dev/null +++ b/apps/backend/internal/api/notify.go @@ -0,0 +1,96 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "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. + +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"` +} + +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 +} + +// notify fires the webhook asynchronously; delivery failures are logged, never fatal. +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) +} + +func (s *Server) deliver(url string, p notifyPayload) { + body, _ := json.Marshal(p) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return + } + 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) + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err)) + return + } + resp.Body.Close() + if resp.StatusCode >= 400 { + s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode)) + } +} + +// 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 lets the settings UI verify the configured webhook. +func (s *Server) notifyTest(c *gin.Context) { + if s.webhookURL() == "" { + fail(c, http.StatusBadRequest, "notify_webhook not configured") + return + } + s.notify("test", "dash", "Dash test notification") + c.JSON(http.StatusOK, gin.H{"status": "sent"}) +} diff --git a/apps/frontend/src/components/SettingsDialog.tsx b/apps/frontend/src/components/SettingsDialog.tsx index 8b72b45..489fe61 100644 --- a/apps/frontend/src/components/SettingsDialog.tsx +++ b/apps/frontend/src/components/SettingsDialog.tsx @@ -1,12 +1,14 @@ -import { useRef, useState } from "react"; -import { Download, Upload } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Bell, Download, Upload } from "lucide-react"; import { useBoard } from "@/board/store"; +import { api } from "@/api"; import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -14,6 +16,23 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh const board = useBoard(); const fileRef = useRef(null); const [msg, setMsg] = useState(""); + const [webhook, setWebhook] = useState(""); + const [webhookMsg, setWebhookMsg] = useState(""); + + useEffect(() => { + if (!open) return; + void api.GET("/api/settings").then(({ data }) => { + const v = data?.notify_webhook; + setWebhook(typeof v === "string" ? v : ""); + setWebhookMsg(""); + }); + }, [open]); + + async function saveWebhook() { + setWebhookMsg(""); + await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } }); + setWebhookMsg("saved"); + } async function exportJson() { const res = await fetch("/api/export"); @@ -93,6 +112,38 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh +
+ +
+ { setWebhook(e.target.value); setWebhookMsg(""); }} + placeholder="https://ntfy.sh/dash or Slack/Discord hook" + className="font-mono text-[12px]" + /> + + +
+

+ Monitor up/down flips and domain/certificate expiry post JSON here. + Works with Slack, Discord, ntfy, or any JSON receiver. +

+ {webhookMsg ?

{webhookMsg}

: null} +
+