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 @@ + + +
+ + + ++ state updated {{ .StateAge }} · page generated + {{ .GeneratedAt }} UTC · auto-refresh 30s +
+| Domain | +Nameservers | +Checked | +
|---|---|---|
| + {{ $name }} + | ++ {{ joinStrings $ds.Nameservers ", " }} + | ++ {{ relTime $ds.LastChecked }} + | +
+ No domains configured. +
+ {{ end }} +| Hostname | +NS | +Status | +Records | +Checked | +
|---|---|---|---|---|
| + {{ $name }} + | ++ {{ $ns }} + | ++ {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + | ++ {{ formatRecords $nsr.Records }} + | ++ {{ relTime $nsr.LastChecked }} + | +
+ No hostnames configured. +
+ {{ end }} +| Address | +State | +Hostnames | +Checked | +
|---|---|---|---|
| + {{ $key }} + | ++ {{ if $ps.Open }} + open + {{ else }} + closed + {{ end }} + | ++ {{ joinStrings $ps.Hostnames ", " }} + | ++ {{ relTime $ps.LastChecked }} + | +
+ No port data yet. +
+ {{ end }} +| Endpoint | +Status | +CN | +Issuer | +Expires | +Checked | +
|---|---|---|---|---|---|
| + {{ $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 }} + | +
+ No certificate data yet. +
+ {{ end }} ++ {{ .Message }} +
++ No alerts recorded since last restart. +
+ {{ end }} +