From 713a758c83c59afe0edc35bb55f8caac2914e381 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 4 Mar 2026 03:06:07 -0800 Subject: [PATCH] feat: add unauthenticated web dashboard showing monitoring state and recent alerts Add a read-only web dashboard at GET / that displays: - Summary counts for all monitored resources - Domain nameserver state with per-NS records and status - Hostname DNS records per authoritative nameserver - TCP port open/closed state with associated hostnames - TLS certificate details (CN, issuer, expiry, status) - Last 100 alerts in reverse chronological order Every data point shows relative age (e.g. '5m ago') for freshness. Page auto-refreshes every 30 seconds via meta refresh. Uses Tailwind CSS via CDN for a dark, technical aesthetic with saturated teals and blues on dark slate. Single page, no navigation. Implementation: - internal/notify/history.go: thread-safe ring buffer (last 100 alerts) - internal/notify/notify.go: record alerts in history before dispatch, refactor SendNotification into smaller dispatch helpers (funlen) - internal/handlers/dashboard.go: template rendering with embedded HTML, helper functions for relative time, record formatting, expiry days - internal/handlers/templates/dashboard.html: Tailwind-styled dashboard - internal/handlers/handlers.go: add State and Notify dependencies - internal/server/routes.go: register GET / dashboard route - README.md: document dashboard and new / endpoint No secrets (webhook URLs, API tokens, notification endpoints) are exposed in the dashboard. closes #82 --- README.md | 22 ++ internal/handlers/dashboard.go | 151 ++++++++ internal/handlers/dashboard_test.go | 80 +++++ internal/handlers/export_test.go | 18 + internal/handlers/handlers.go | 24 +- internal/handlers/templates/dashboard.html | 386 +++++++++++++++++++++ internal/notify/export_test.go | 1 + internal/notify/history.go | 62 ++++ internal/notify/history_test.go | 88 +++++ internal/notify/notify.go | 124 ++++--- internal/server/routes.go | 3 + 11 files changed, 907 insertions(+), 52 deletions(-) create mode 100644 internal/handlers/dashboard.go create mode 100644 internal/handlers/dashboard_test.go create mode 100644 internal/handlers/export_test.go create mode 100644 internal/handlers/templates/dashboard.html create mode 100644 internal/notify/history.go create mode 100644 internal/notify/history_test.go diff --git a/README.md b/README.md index c5d104b..44aebab 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,34 @@ includes: - State is written atomically (write to temp file, then rename) to prevent corruption. +### Web Dashboard + +dnswatcher includes an unauthenticated, read-only web dashboard at the +root URL (`/`). It displays: + +- **Summary counts** for monitored domains, hostnames, ports, and + certificates. +- **Domains** with their discovered nameservers. +- **Hostnames** with per-nameserver DNS records and status. +- **Ports** with open/closed state and associated hostnames. +- **TLS certificates** with CN, issuer, expiry, and status. +- **Recent alerts** (last 100 notifications sent since the process + started), displayed in reverse chronological order. + +Every data point shows its age (e.g. "5m ago") so you can tell at a +glance how fresh the information is. The page auto-refreshes every 30 +seconds. + +The dashboard intentionally does not expose any configuration details +such as webhook URLs, notification endpoints, or API tokens. + ### HTTP API dnswatcher exposes a lightweight HTTP API for operational visibility: | Endpoint | Description | |---------------------------------------|--------------------------------| +| `GET /` | Web dashboard (HTML) | | `GET /health` | Health check (JSON) | | `GET /api/v1/status` | Current monitoring state | | `GET /metrics` | Prometheus metrics (optional) | diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..6d4ac57 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "embed" + "fmt" + "html/template" + "math" + "net/http" + "strings" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" + "sneak.berlin/go/dnswatcher/internal/state" +) + +//go:embed templates/dashboard.html +var dashboardFS embed.FS + +// Time unit constants for relative time calculations. +const ( + secondsPerMinute = 60 + minutesPerHour = 60 + hoursPerDay = 24 +) + +// newDashboardTemplate parses the embedded dashboard HTML +// template with helper functions. +func newDashboardTemplate() *template.Template { + funcs := template.FuncMap{ + "relTime": relTime, + "joinStrings": joinStrings, + "formatRecords": formatRecords, + "expiryDays": expiryDays, + } + + return template.Must( + template.New("dashboard.html"). + Funcs(funcs). + ParseFS(dashboardFS, "templates/dashboard.html"), + ) +} + +// dashboardData is the data passed to the dashboard template. +type dashboardData struct { + Snapshot state.Snapshot + Alerts []notify.AlertEntry + StateAge string + GeneratedAt string +} + +// HandleDashboard returns the dashboard page handler. +func (h *Handlers) HandleDashboard() http.HandlerFunc { + tmpl := newDashboardTemplate() + + return func( + writer http.ResponseWriter, + _ *http.Request, + ) { + snap := h.state.GetSnapshot() + alerts := h.notifyHistory.Recent() + + data := dashboardData{ + Snapshot: snap, + Alerts: alerts, + StateAge: relTime(snap.LastUpdated), + GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), + } + + writer.Header().Set( + "Content-Type", "text/html; charset=utf-8", + ) + + err := tmpl.Execute(writer, data) + if err != nil { + h.log.Error( + "dashboard template error", + "error", err, + ) + } + } +} + +// relTime returns a human-readable relative time string such +// as "2 minutes ago" or "never" for zero times. +func relTime(t time.Time) string { + if t.IsZero() { + return "never" + } + + d := time.Since(t) + if d < 0 { + return "just now" + } + + seconds := int(math.Round(d.Seconds())) + if seconds < secondsPerMinute { + return fmt.Sprintf("%ds ago", seconds) + } + + minutes := seconds / secondsPerMinute + if minutes < minutesPerHour { + return fmt.Sprintf("%dm ago", minutes) + } + + hours := minutes / minutesPerHour + if hours < hoursPerDay { + return fmt.Sprintf( + "%dh %dm ago", hours, minutes%minutesPerHour, + ) + } + + days := hours / hoursPerDay + + return fmt.Sprintf( + "%dd %dh ago", days, hours%hoursPerDay, + ) +} + +// joinStrings joins a string slice with a separator. +func joinStrings(items []string, sep string) string { + return strings.Join(items, sep) +} + +// formatRecords formats a map of record type → values into a +// compact display string. +func formatRecords(records map[string][]string) string { + if len(records) == 0 { + return "-" + } + + var parts []string + + for rtype, values := range records { + for _, v := range values { + parts = append(parts, rtype+": "+v) + } + } + + return strings.Join(parts, ", ") +} + +// expiryDays returns the number of days until the given time, +// rounded down. Returns 0 if already expired. +func expiryDays(t time.Time) int { + d := time.Until(t).Hours() / hoursPerDay + if d < 0 { + return 0 + } + + return int(d) +} diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go new file mode 100644 index 0000000..554cf9d --- /dev/null +++ b/internal/handlers/dashboard_test.go @@ -0,0 +1,80 @@ +package handlers_test + +import ( + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/handlers" +) + +func TestRelTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dur time.Duration + want string + }{ + {"zero", 0, "never"}, + {"seconds", 30 * time.Second, "30s ago"}, + {"minutes", 5 * time.Minute, "5m ago"}, + {"hours", 2*time.Hour + 15*time.Minute, "2h 15m ago"}, + {"days", 48*time.Hour + 3*time.Hour, "2d 3h ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var input time.Time + if tt.dur > 0 { + input = time.Now().Add(-tt.dur) + } + + got := handlers.RelTime(input) + if got != tt.want { + t.Errorf( + "RelTime(%v) = %q, want %q", + tt.dur, got, tt.want, + ) + } + }) + } +} + +func TestExpiryDays(t *testing.T) { + t.Parallel() + + // 10 days from now. + future := time.Now().Add(10 * 24 * time.Hour) + + days := handlers.ExpiryDays(future) + if days < 9 || days > 10 { + t.Errorf("expected ~10 days, got %d", days) + } + + // Already expired. + past := time.Now().Add(-24 * time.Hour) + + days = handlers.ExpiryDays(past) + if days != 0 { + t.Errorf("expected 0 for expired, got %d", days) + } +} + +func TestFormatRecords(t *testing.T) { + t.Parallel() + + got := handlers.FormatRecords(nil) + if got != "-" { + t.Errorf("expected -, got %q", got) + } + + got = handlers.FormatRecords(map[string][]string{ + "A": {"1.2.3.4"}, + }) + + if got != "A: 1.2.3.4" { + t.Errorf("unexpected format: %q", got) + } +} diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go new file mode 100644 index 0000000..53165b9 --- /dev/null +++ b/internal/handlers/export_test.go @@ -0,0 +1,18 @@ +package handlers + +import "time" + +// RelTime exports relTime for testing. +func RelTime(t time.Time) string { + return relTime(t) +} + +// ExpiryDays exports expiryDays for testing. +func ExpiryDays(t time.Time) int { + return expiryDays(t) +} + +// FormatRecords exports formatRecords for testing. +func FormatRecords(records map[string][]string) string { + return formatRecords(records) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 474c1bc..1ecd7e2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -11,6 +11,8 @@ import ( "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/healthcheck" "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/notify" + "sneak.berlin/go/dnswatcher/internal/state" ) // Params contains dependencies for Handlers. @@ -20,23 +22,29 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals Healthcheck *healthcheck.Healthcheck + State *state.State + Notify *notify.Service } // Handlers provides HTTP request handlers. type Handlers struct { - log *slog.Logger - params *Params - globals *globals.Globals - hc *healthcheck.Healthcheck + log *slog.Logger + params *Params + globals *globals.Globals + hc *healthcheck.Healthcheck + state *state.State + notifyHistory *notify.AlertHistory } // New creates a new Handlers instance. func New(_ fx.Lifecycle, params Params) (*Handlers, error) { return &Handlers{ - log: params.Logger.Get(), - params: ¶ms, - globals: params.Globals, - hc: params.Healthcheck, + log: params.Logger.Get(), + params: ¶ms, + globals: params.Globals, + hc: params.Healthcheck, + state: params.State, + notifyHistory: params.Notify.History(), }, nil } diff --git a/internal/handlers/templates/dashboard.html b/internal/handlers/templates/dashboard.html new file mode 100644 index 0000000..8da6ba4 --- /dev/null +++ b/internal/handlers/templates/dashboard.html @@ -0,0 +1,386 @@ + + + + + + + dnswatcher + + + + +
+ {{/* ---- Header ---- */}} +
+

+ dnswatcher +

+

+ state updated {{ .StateAge }} · page generated + {{ .GeneratedAt }} UTC · auto-refresh 30s +

+
+ + {{/* ---- Summary bar ---- */}} +
+
+
+ Domains +
+
+ {{ len .Snapshot.Domains }} +
+
+
+
+ Hostnames +
+
+ {{ len .Snapshot.Hostnames }} +
+
+
+
+ Ports +
+
+ {{ len .Snapshot.Ports }} +
+
+
+
+ Certificates +
+
+ {{ len .Snapshot.Certificates }} +
+
+
+ + {{/* ---- Domains ---- */}} +
+

+ Domains +

+ {{ if .Snapshot.Domains }} +
+ + + + + + + + + + {{ range $name, $ds := .Snapshot.Domains }} + + + + + + {{ end }} + +
DomainNameserversChecked
+ {{ $name }} + + {{ joinStrings $ds.Nameservers ", " }} + + {{ relTime $ds.LastChecked }} +
+
+ {{ else }} +

+ No domains configured. +

+ {{ end }} +
+ + {{/* ---- Hostnames ---- */}} +
+

+ Hostnames +

+ {{ if .Snapshot.Hostnames }} +
+ + + + + + + + + + + + {{ range $name, $hs := .Snapshot.Hostnames }} + {{ range $ns, $nsr := $hs.RecordsByNameserver }} + + + + + + + + {{ end }} + {{ end }} + +
HostnameNSStatusRecordsChecked
+ {{ $name }} + + {{ $ns }} + + {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + + {{ formatRecords $nsr.Records }} + + {{ relTime $nsr.LastChecked }} +
+
+ {{ else }} +

+ No hostnames configured. +

+ {{ end }} +
+ + {{/* ---- Ports ---- */}} +
+

+ Ports +

+ {{ if .Snapshot.Ports }} +
+ + + + + + + + + + + {{ range $key, $ps := .Snapshot.Ports }} + + + + + + + {{ end }} + +
AddressStateHostnamesChecked
+ {{ $key }} + + {{ if $ps.Open }} + open + {{ else }} + closed + {{ end }} + + {{ joinStrings $ps.Hostnames ", " }} + + {{ relTime $ps.LastChecked }} +
+
+ {{ else }} +

+ No port data yet. +

+ {{ end }} +
+ + {{/* ---- Certificates ---- */}} +
+

+ Certificates +

+ {{ if .Snapshot.Certificates }} +
+ + + + + + + + + + + + + {{ range $key, $cs := .Snapshot.Certificates }} + + + + + + + + + {{ end }} + +
EndpointStatusCNIssuerExpiresChecked
+ {{ $key }} + + {{ if eq $cs.Status "ok" }} + ok + {{ else }} + {{ $cs.Status }} + {{ end }} + + {{ $cs.CommonName }} + + {{ $cs.Issuer }} + + {{ if not $cs.NotAfter.IsZero }} + {{ $days := expiryDays $cs.NotAfter }} + {{ if lt $days 7 }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ else if lt $days 30 }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ else }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ end }} + {{ end }} + + {{ relTime $cs.LastChecked }} +
+
+ {{ else }} +

+ No certificate data yet. +

+ {{ end }} +
+ + {{/* ---- Recent Alerts ---- */}} +
+

+ Recent Alerts ({{ len .Alerts }}) +

+ {{ if .Alerts }} +
+ {{ range .Alerts }} +
+
+ {{ if eq .Priority "error" }} + error + {{ else if eq .Priority "warning" }} + warning + {{ else if eq .Priority "success" }} + success + {{ else }} + info + {{ end }} + + {{ .Title }} + + + {{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC + ({{ relTime .Timestamp }}) + +
+

+ {{ .Message }} +

+
+ {{ end }} +
+ {{ else }} +

+ No alerts recorded since last restart. +

+ {{ end }} +
+ + {{/* ---- Footer ---- */}} +
+ dnswatcher · monitoring {{ len .Snapshot.Domains }} domains + + {{ len .Snapshot.Hostnames }} hostnames +
+
+ + diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go index f2671e0..0f02fdd 100644 --- a/internal/notify/export_test.go +++ b/internal/notify/export_test.go @@ -34,6 +34,7 @@ func NewTestService(transport http.RoundTripper) *Service { return &Service{ log: slog.New(slog.DiscardHandler), transport: transport, + history: NewAlertHistory(), } } diff --git a/internal/notify/history.go b/internal/notify/history.go new file mode 100644 index 0000000..53a9b97 --- /dev/null +++ b/internal/notify/history.go @@ -0,0 +1,62 @@ +package notify + +import ( + "sync" + "time" +) + +// maxAlertHistory is the maximum number of alerts to retain. +const maxAlertHistory = 100 + +// AlertEntry represents a single notification that was sent. +type AlertEntry struct { + Timestamp time.Time + Title string + Message string + Priority string +} + +// AlertHistory is a thread-safe ring buffer that stores +// the most recent alerts. +type AlertHistory struct { + mu sync.RWMutex + entries [maxAlertHistory]AlertEntry + count int + index int +} + +// NewAlertHistory creates a new empty AlertHistory. +func NewAlertHistory() *AlertHistory { + return &AlertHistory{} +} + +// Add records a new alert entry in the ring buffer. +func (h *AlertHistory) Add(entry AlertEntry) { + h.mu.Lock() + defer h.mu.Unlock() + + h.entries[h.index] = entry + h.index = (h.index + 1) % maxAlertHistory + + if h.count < maxAlertHistory { + h.count++ + } +} + +// Recent returns the stored alerts in reverse chronological +// order (newest first). Returns at most maxAlertHistory entries. +func (h *AlertHistory) Recent() []AlertEntry { + h.mu.RLock() + defer h.mu.RUnlock() + + result := make([]AlertEntry, h.count) + + for i := range h.count { + // Walk backwards from the most recent entry. + idx := (h.index - 1 - i + maxAlertHistory) % + maxAlertHistory + result[i] = h.entries[idx] + } + + return result +} diff --git a/internal/notify/history_test.go b/internal/notify/history_test.go new file mode 100644 index 0000000..a60804d --- /dev/null +++ b/internal/notify/history_test.go @@ -0,0 +1,88 @@ +package notify_test + +import ( + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" +) + +func TestAlertHistoryEmpty(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + entries := h.Recent() + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestAlertHistoryAddAndRecent(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + now := time.Now().UTC() + + h.Add(notify.AlertEntry{ + Timestamp: now.Add(-2 * time.Minute), + Title: "first", + Message: "msg1", + Priority: "info", + }) + + h.Add(notify.AlertEntry{ + Timestamp: now.Add(-1 * time.Minute), + Title: "second", + Message: "msg2", + Priority: "warning", + }) + + entries := h.Recent() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Newest first. + if entries[0].Title != "second" { + t.Errorf( + "expected newest first, got %q", entries[0].Title, + ) + } + + if entries[1].Title != "first" { + t.Errorf( + "expected oldest second, got %q", entries[1].Title, + ) + } +} + +func TestAlertHistoryOverflow(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + const totalEntries = 110 + + // Fill beyond capacity. + for i := range totalEntries { + h.Add(notify.AlertEntry{ + Timestamp: time.Now().UTC(), + Title: "alert", + Message: "msg", + Priority: string(rune('0' + i%10)), + }) + } + + entries := h.Recent() + + const maxHistory = 100 + + if len(entries) != maxHistory { + t.Fatalf( + "expected %d entries, got %d", + maxHistory, len(entries), + ) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index c61e092..b36be7c 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -112,6 +112,7 @@ type Service struct { ntfyURL *url.URL slackWebhookURL *url.URL mattermostWebhookURL *url.URL + history *AlertHistory } // New creates a new notify Service. @@ -123,6 +124,7 @@ func New( log: params.Logger.Get(), transport: http.DefaultTransport, config: params.Config, + history: NewAlertHistory(), } if params.Config.NtfyTopic != "" { @@ -167,65 +169,99 @@ func New( return svc, nil } +// History returns the alert history for reading recent alerts. +func (svc *Service) History() *AlertHistory { + return svc.history +} + // SendNotification sends a notification to all configured -// endpoints. +// endpoints and records it in the alert history. func (svc *Service) SendNotification( ctx context.Context, title, message, priority string, ) { - if svc.ntfyURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + svc.history.Add(AlertEntry{ + Timestamp: time.Now().UTC(), + Title: title, + Message: message, + Priority: priority, + }) - err := svc.sendNtfy( - notifyCtx, - svc.ntfyURL, - title, message, priority, - ) - if err != nil { - svc.log.Error( - "failed to send ntfy notification", - "error", err, - ) - } - }() + svc.dispatchNtfy(ctx, title, message, priority) + svc.dispatchSlack(ctx, title, message, priority) + svc.dispatchMattermost(ctx, title, message, priority) +} + +func (svc *Service) dispatchNtfy( + ctx context.Context, + title, message, priority string, +) { + if svc.ntfyURL == nil { + return } - if svc.slackWebhookURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.slackWebhookURL, - title, message, priority, + err := svc.sendNtfy( + notifyCtx, svc.ntfyURL, + title, message, priority, + ) + if err != nil { + svc.log.Error( + "failed to send ntfy notification", + "error", err, ) - if err != nil { - svc.log.Error( - "failed to send slack notification", - "error", err, - ) - } - }() + } + }() +} + +func (svc *Service) dispatchSlack( + ctx context.Context, + title, message, priority string, +) { + if svc.slackWebhookURL == nil { + return } - if svc.mattermostWebhookURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.mattermostWebhookURL, - title, message, priority, + err := svc.sendSlack( + notifyCtx, svc.slackWebhookURL, + title, message, priority, + ) + if err != nil { + svc.log.Error( + "failed to send slack notification", + "error", err, ) - if err != nil { - svc.log.Error( - "failed to send mattermost notification", - "error", err, - ) - } - }() + } + }() +} + +func (svc *Service) dispatchMattermost( + ctx context.Context, + title, message, priority string, +) { + if svc.mattermostWebhookURL == nil { + return } + + go func() { + notifyCtx := context.WithoutCancel(ctx) + + err := svc.sendSlack( + notifyCtx, svc.mattermostWebhookURL, + title, message, priority, + ) + if err != nil { + svc.log.Error( + "failed to send mattermost notification", + "error", err, + ) + } + }() } func (svc *Service) sendNtfy( diff --git a/internal/server/routes.go b/internal/server/routes.go index cd07ba1..7303897 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -22,6 +22,9 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) + // Dashboard (read-only web UI) + s.router.Get("/", s.handlers.HandleDashboard()) + // Health check s.router.Get("/health", s.handlers.HandleHealthCheck())