diff --git a/Makefile b/Makefile index fe266fc..0aadca3 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,7 @@ BINARY := dnswatcher VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -BUILDARCH := $(shell go env GOARCH) -LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH) +LDFLAGS := -X main.Version=$(VERSION) all: check build diff --git a/README.md b/README.md index c5d104b..eb52839 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,41 @@ 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. + +All assets (CSS) are embedded in the binary and served from the +application itself. The dashboard makes zero external HTTP requests — +no CDN dependencies or third-party resources are loaded at runtime. + ### HTTP API dnswatcher exposes a lightweight HTTP API for operational visibility: | Endpoint | Description | |---------------------------------------|--------------------------------| -| `GET /health` | Health check (JSON) | +| `GET /` | Web dashboard (HTML) | +| `GET /s/...` | Static assets (embedded CSS) | +| `GET /.well-known/healthcheck` | Health check (JSON) | +| `GET /health` | Health check (JSON, legacy) | | `GET /api/v1/status` | Current monitoring state | | `GET /metrics` | Prometheus metrics (optional) | @@ -143,7 +171,7 @@ cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) internal/ config/config.go Viper-based configuration - globals/globals.go Build-time variables (version, arch) + globals/globals.go Build-time variables (version) logger/logger.go slog structured logging (TTY detection) healthcheck/healthcheck.go Health check service middleware/middleware.go HTTP middleware (logging, CORS, metrics auth) @@ -335,11 +363,10 @@ make clean # Remove build artifacts ### Build-Time Variables -Version and architecture are injected via `-ldflags`: +Version is injected via `-ldflags`: ```sh -go build -ldflags "-X main.Version=$(git describe --tags --always) \ - -X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher +go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher ``` --- diff --git a/cmd/dnswatcher/main.go b/cmd/dnswatcher/main.go index 03b38da..baff13b 100644 --- a/cmd/dnswatcher/main.go +++ b/cmd/dnswatcher/main.go @@ -25,15 +25,13 @@ import ( // //nolint:gochecknoglobals // build-time variables var ( - Appname = "dnswatcher" - Version string - Buildarch string + Appname = "dnswatcher" + Version string ) func main() { globals.SetAppname(Appname) globals.SetVersion(Version) - globals.SetBuildarch(Buildarch) fx.New( fx.Provide( diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c460d56..0ee5c83 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,9 +19,8 @@ func newTestParams(t *testing.T) config.Params { t.Helper() g := &globals.Globals{ - Appname: "dnswatcher", - Version: "test", - Buildarch: "amd64", + Appname: "dnswatcher", + Version: "test", } l, err := logger.New(nil, logger.Params{Globals: g}) diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 02ce645..0b51cff 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -12,17 +12,15 @@ import ( // //nolint:gochecknoglobals // Required for ldflags injection at build time var ( - mu sync.RWMutex - appname string - version string - buildarch string + mu sync.RWMutex + appname string + version string ) // Globals holds build-time variables for dependency injection. type Globals struct { - Appname string - Version string - Buildarch string + Appname string + Version string } // New creates a new Globals instance from package-level variables. @@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) { defer mu.RUnlock() return &Globals{ - Appname: appname, - Version: version, - Buildarch: buildarch, + Appname: appname, + Version: version, }, nil } @@ -52,11 +49,3 @@ func SetVersion(ver string) { version = ver } - -// SetBuildarch sets the build architecture. -func SetBuildarch(arch string) { - mu.Lock() - defer mu.Unlock() - - buildarch = arch -} 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..5ee6063 --- /dev/null +++ b/internal/handlers/templates/dashboard.html @@ -0,0 +1,370 @@ + + + + + + + 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/logger/logger.go b/internal/logger/logger.go index 0bcdd28..8aaaad1 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -78,6 +78,5 @@ func (l *Logger) Identify() { l.log.Info("starting", "appname", l.params.Globals.Appname, "version", l.params.Globals.Version, - "buildarch", l.params.Globals.Buildarch, ) } 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..fa99177 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,11 +1,14 @@ package server import ( + "net/http" "time" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" + + "sneak.berlin/go/dnswatcher/static" ) // requestTimeout is the maximum duration for handling a request. @@ -22,7 +25,25 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) - // Health check + // Dashboard (read-only web UI) + s.router.Get("/", s.handlers.HandleDashboard()) + + // Static assets (embedded CSS/JS) + s.router.Mount( + "/s", + http.StripPrefix( + "/s", + http.FileServer(http.FS(static.Static)), + ), + ) + + // Health check (standard well-known path) + s.router.Get( + "/.well-known/healthcheck", + s.handlers.HandleHealthCheck(), + ) + + // Legacy health check (keep for backward compatibility) s.router.Get("/health", s.handlers.HandleHealthCheck()) // API v1 routes diff --git a/static/css/tailwind.min.css b/static/css/tailwind.min.css new file mode 100644 index 0000000..ade9507 --- /dev/null +++ b/static/css/tailwind.min.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xs{max-width:20rem}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-amber-700\/30{border-color:rgba(180,83,9,.3)}.border-amber-700\/40{border-color:rgba(180,83,9,.4)}.border-blue-700\/30{border-color:rgba(29,78,216,.3)}.border-blue-700\/40{border-color:rgba(29,78,216,.4)}.border-red-700\/30{border-color:rgba(185,28,28,.3)}.border-red-700\/40{border-color:rgba(185,28,28,.4)}.border-slate-700\/50{border-color:rgba(51,65,85,.5)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-teal-700\/30{border-color:rgba(15,118,110,.3)}.border-teal-700\/40{border-color:rgba(15,118,110,.4)}.bg-amber-900\/50{background-color:rgba(120,53,15,.5)}.bg-blue-900\/50{background-color:rgba(30,58,138,.5)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-surface-800{--tw-bg-opacity:1;background-color:rgb(26 35 50/var(--tw-bg-opacity,1))}.bg-surface-950{--tw-bg-opacity:1;background-color:rgb(12 18 34/var(--tw-bg-opacity,1))}.bg-teal-900\/50{background-color:rgba(19,78,74,.5)}.p-4{padding:1rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.hover\:bg-surface-800\/50:hover{background-color:rgba(26,35,50,.5)}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..28ee133 --- /dev/null +++ b/static/static.go @@ -0,0 +1,10 @@ +// Package static provides embedded static assets. +package static + +import "embed" + +// Static contains the embedded static assets (CSS, JS) served +// at the /s/ URL prefix. +// +//go:embed css +var Static embed.FS