diff --git a/cmd/exploredns-receiver/main.go b/cmd/exploredns-receiver/main.go new file mode 100644 index 0000000..ff5fca9 --- /dev/null +++ b/cmd/exploredns-receiver/main.go @@ -0,0 +1,86 @@ +// Command exploredns-receiver stores usage webhooks posted by the +// ExploreDNS API server in MySQL or SQLite. +// +// Configuration (environment): +// +// RECEIVER_ADDR listen address (default ":8080") +// RECEIVER_MYSQL_DSN go-sql-driver DSN; when set, events go to MySQL +// RECEIVER_SQLITE_PATH SQLite fallback path (default "data/exploredns-receiver.db") +// RECEIVER_INGEST_TOKEN bearer token required on POST /webhook (open when unset) +// RECEIVER_ADMIN_USER basic-auth username for /admin (default "admin") +// RECEIVER_ADMIN_PASSWORD basic-auth password for /admin (required) +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/server" + "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store" +) + +// version is stamped at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + addr := envOr("RECEIVER_ADDR", ":8080") + + adminPass := os.Getenv("RECEIVER_ADMIN_PASSWORD") + if adminPass == "" { + fmt.Fprintln(os.Stderr, "Error: RECEIVER_ADMIN_PASSWORD is not set; refusing to start with an unprotected admin interface") + os.Exit(1) + } + + var ( + st *store.Store + backend string + err error + ) + if dsn := os.Getenv("RECEIVER_MYSQL_DSN"); dsn != "" { + st, err = store.OpenMySQL(dsn) + backend = "mysql " + store.RedactMySQLDSN(dsn) + } else { + path := envOr("RECEIVER_SQLITE_PATH", "data/exploredns-receiver.db") + st, err = store.OpenSQLite(path) + backend = "sqlite " + path + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + srv := server.New(addr, st) + srv.SetVersion(version) + srv.SetIngestToken(os.Getenv("RECEIVER_INGEST_TOKEN")) + srv.SetAdminAuth(envOr("RECEIVER_ADMIN_USER", "admin"), adminPass) + if err := srv.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + log.Printf("ExploreDNS receiver %s listening on %s, storing to %s", version, srv.Addr(), backend) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down...") + if err := srv.Shutdown(15 * time.Second); err != nil { + log.Printf("Shutdown error: %v", err) + } + if err := st.Close(); err != nil { + log.Printf("Close store error: %v", err) + } +} + +// envOr returns the environment variable name, or def when unset or empty. +func envOr(name, def string) string { + if v := os.Getenv(name); v != "" { + return v + } + return def +} diff --git a/internal/receiver/server/admin.go b/internal/receiver/server/admin.go new file mode 100644 index 0000000..4306aed --- /dev/null +++ b/internal/receiver/server/admin.go @@ -0,0 +1,280 @@ +package server + +import ( + "crypto/sha256" + "crypto/subtle" + _ "embed" + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store" +) + +//go:embed static/admin.html +var adminHTML []byte + +const ( + maxListLimit = 200 + defaultStatsDays = 30 + maxStatsDays = 365 + topLimit = 10 +) + +// requireAdmin gates next behind HTTP basic auth against the configured +// admin credentials. An empty configured password locks the subtree +// entirely rather than opening it. +func (h *handler) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + // Evaluate both comparisons unconditionally to keep timing uniform. + userOK := secretEqual(user, h.adminUser) + passOK := secretEqual(pass, h.adminPass) + if !ok || !userOK || !passOK || h.adminPass == "" { + w.Header().Set("WWW-Authenticate", `Basic realm="ExploreDNS receiver admin", charset="UTF-8"`) + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + next.ServeHTTP(w, r) + }) +} + +// secretEqual compares two strings in constant time; both sides are hashed +// first so length differences do not leak. +func secretEqual(got, want string) bool { + g := sha256.Sum256([]byte(got)) + w := sha256.Sum256([]byte(want)) + return subtle.ConstantTimeCompare(g[:], w[:]) == 1 +} + +// adminPage handles GET /admin, serving the embedded admin UI. +func (h *handler) adminPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(adminHTML) //nolint:errcheck +} + +// traversalItem is the admin API JSON shape of one stored traversal. +// Completion fields are omitted while a traversal is still running. +type traversalItem struct { + ID string `json:"id"` + Domain string `json:"domain"` + QueryType string `json:"query_type"` + AllRoots bool `json:"all_roots"` + ClientIP string `json:"client_ip"` + StartedAt string `json:"started_at,omitempty"` + DoneAt string `json:"done_at,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + ResultCount *int `json:"result_count,omitempty"` + Summary json.RawMessage `json:"summary,omitempty"` + ReceivedAt string `json:"received_at"` +} + +func toItem(tr store.Traversal) traversalItem { + it := traversalItem{ + ID: tr.ID, Domain: tr.Domain, QueryType: tr.QueryType, AllRoots: tr.AllRoots, + ClientIP: tr.ClientIP, StartedAt: rfc3339(tr.StartedAt), Status: tr.Status, + Error: tr.Error, DurationMS: tr.DurationMS, ResultCount: tr.ResultCount, + ReceivedAt: rfc3339(tr.FirstSeen), + } + if tr.DoneAt != nil { + it.DoneAt = rfc3339(*tr.DoneAt) + } + if tr.Summary != "" { + it.Summary = json.RawMessage(tr.Summary) + } + return it +} + +// rfc3339 renders t as UTC RFC 3339, or "" for the zero time. +func rfc3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// adminTraversals handles GET /admin/api/traversals. +func (h *handler) adminTraversals(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + limit := 50 + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeError(w, http.StatusBadRequest, "invalid limit "+strconv.Quote(v)) + return + } + limit = min(n, maxListLimit) + } + offset := 0 + if v := q.Get("offset"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + writeError(w, http.StatusBadRequest, "invalid offset "+strconv.Quote(v)) + return + } + offset = n + } + f := store.ListFilter{Domain: q.Get("domain"), Status: q.Get("status")} + if v := q.Get("from"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid from: "+err.Error()) + return + } + f.From = t + } + if v := q.Get("to"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid to: "+err.Error()) + return + } + f.To = t + } + + rows, total, err := h.st.ListTraversals(r.Context(), f, limit, offset) + if err != nil { + log.Printf("receiver admin: list traversals: %v", err) + writeError(w, http.StatusInternalServerError, "list traversals failed") + return + } + items := make([]traversalItem, 0, len(rows)) + for _, tr := range rows { + items = append(items, toItem(tr)) + } + writeJSON(w, http.StatusOK, map[string]any{"total": total, "items": items}) +} + +type statsResponse struct { + Days int `json:"days"` + Totals statsTotals `json:"totals"` + PerDay []dayJSON `json:"per_day"` + TopDomains []nameCountJSON `json:"top_domains"` + QueryTypes []nameCountJSON `json:"query_types"` + Statuses []nameCountJSON `json:"statuses"` + Durations durationsJSON `json:"durations"` + TopClients []nameCountJSON `json:"top_clients"` +} + +type statsTotals struct { + AllTime int `json:"all_time"` + Last24h int `json:"last_24h"` + Last7d int `json:"last_7d"` + DistinctDomains int `json:"distinct_domains"` + DistinctClients int `json:"distinct_clients"` + ErrorRate float64 `json:"error_rate"` +} + +type dayJSON struct { + Day string `json:"day"` + Total int `json:"total"` + Errors int `json:"errors"` +} + +type nameCountJSON struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type durationsJSON struct { + Count int `json:"count"` + AvgMS float64 `json:"avg_ms"` + P50MS int64 `json:"p50_ms"` + P95MS int64 `json:"p95_ms"` +} + +func nameCounts(rows []store.NameCount) []nameCountJSON { + out := make([]nameCountJSON, 0, len(rows)) + for _, r := range rows { + out = append(out, nameCountJSON{Name: r.Name, Count: r.Count}) + } + return out +} + +// adminStats handles GET /admin/api/stats. The totals block uses fixed +// windows; everything else covers the last ?days calendar days (UTC), +// matching the StatsPerDay window. +func (h *handler) adminStats(w http.ResponseWriter, r *http.Request) { + days := defaultStatsDays + if v := r.URL.Query().Get("days"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeError(w, http.StatusBadRequest, "invalid days "+strconv.Quote(v)) + return + } + days = min(n, maxStatsDays) + } + + ctx := r.Context() + now := time.Now().UTC() + since := now.Truncate(24*time.Hour).AddDate(0, 0, -(days - 1)) + + fail := func(what string, err error) { + log.Printf("receiver admin: %s: %v", what, err) + writeError(w, http.StatusInternalServerError, what+" failed") + } + totals, err := h.st.Totals(ctx, now) + if err != nil { + fail("totals", err) + return + } + perDay, err := h.st.StatsPerDay(ctx, days) + if err != nil { + fail("per-day stats", err) + return + } + topDomains, err := h.st.TopDomains(ctx, since, topLimit) + if err != nil { + fail("top domains", err) + return + } + queryTypes, err := h.st.QueryTypeCounts(ctx, since) + if err != nil { + fail("query types", err) + return + } + statuses, err := h.st.StatusCounts(ctx, since) + if err != nil { + fail("statuses", err) + return + } + dur, err := h.st.Durations(ctx, since) + if err != nil { + fail("durations", err) + return + } + topClients, err := h.st.TopClientIPs(ctx, since, topLimit) + if err != nil { + fail("top clients", err) + return + } + + resp := statsResponse{ + Days: days, + Totals: statsTotals{ + AllTime: totals.AllTime, + Last24h: totals.Last24h, + Last7d: totals.Last7d, + DistinctDomains: totals.DistinctDomains, + DistinctClients: totals.DistinctClients, + }, + PerDay: make([]dayJSON, 0, len(perDay)), + TopDomains: nameCounts(topDomains), + QueryTypes: nameCounts(queryTypes), + Statuses: nameCounts(statuses), + Durations: durationsJSON{Count: dur.Count, AvgMS: dur.AvgMS, P50MS: dur.P50MS, P95MS: dur.P95MS}, + TopClients: nameCounts(topClients), + } + if totals.AllTime > 0 { + resp.Totals.ErrorRate = float64(totals.Errors) / float64(totals.AllTime) + } + for _, d := range perDay { + resp.PerDay = append(resp.PerDay, dayJSON{Day: d.Day, Total: d.Total, Errors: d.Errors}) + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/receiver/server/admin_test.go b/internal/receiver/server/admin_test.go new file mode 100644 index 0000000..86e3beb --- /dev/null +++ b/internal/receiver/server/admin_test.go @@ -0,0 +1,482 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store" +) + +const ( + testAdminUser = "admin" + testAdminPass = "swordfish" +) + +// get performs a GET with optional basic-auth credentials. +func get(h http.Handler, path, user, pass string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + if user != "" || pass != "" { + req.SetBasicAuth(user, pass) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func TestStartRequiresAdminPassword(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "receiver.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer st.Close() + + srv := New("127.0.0.1:0", st) + err = srv.Start() + if err == nil { + srv.Shutdown(time.Second) //nolint:errcheck + t.Fatal("Start succeeded without an admin password") + } + if !strings.Contains(err.Error(), "RECEIVER_ADMIN_PASSWORD") { + t.Errorf("error = %q, want mention of RECEIVER_ADMIN_PASSWORD", err) + } +} + +func TestAdminAuthMatrix(t *testing.T) { + paths := []string{"/admin", "/admin/api/traversals", "/admin/api/stats"} + creds := []struct { + name string + user, pass string + want int + }{ + {"no credentials", "", "", http.StatusUnauthorized}, + {"wrong user", "root", testAdminPass, http.StatusUnauthorized}, + {"wrong password", testAdminUser, "nope", http.StatusUnauthorized}, + {"correct credentials", testAdminUser, testAdminPass, http.StatusOK}, + } + h, _ := newTestHandler(t, "") + for _, path := range paths { + for _, tc := range creds { + t.Run(path+" "+tc.name, func(t *testing.T) { + w := get(h, path, tc.user, tc.pass) + if w.Code != tc.want { + t.Fatalf("status = %d, want %d (%s)", w.Code, tc.want, w.Body) + } + if tc.want == http.StatusUnauthorized { + if got := w.Header().Get("WWW-Authenticate"); !strings.Contains(got, `Basic realm=`) { + t.Errorf("WWW-Authenticate = %q, want Basic realm", got) + } + } + }) + } + } +} + +// TestAdminSubtreeAuthenticated pins that unknown and unauthenticated paths +// under /admin still 401 rather than falling through to a handler. +func TestAdminSubtreeAuthenticated(t *testing.T) { + h, _ := newTestHandler(t, "") + for _, path := range []string{"/admin/", "/admin/secret", "/admin/api/other"} { + if got := get(h, path, "", "").Code; got != http.StatusUnauthorized { + t.Errorf("GET %s without creds = %d, want 401", path, got) + } + } +} + +// TestAdminLockedWithoutPassword pins that a handler built without an admin +// password rejects everything, even blank credentials. +func TestAdminLockedWithoutPassword(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "receiver.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer st.Close() + h := newHandler(st, "test-version", "", "admin", "") + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req.SetBasicAuth("admin", "") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +// TestWebhookAndHealthzStayOpen asserts the sender and k8s probe endpoints +// need no admin credentials. +func TestWebhookAndHealthzStayOpen(t *testing.T) { + h, _ := newTestHandler(t, "") + if got := post(h, startJSON, nil).Code; got != http.StatusNoContent { + t.Errorf("POST /webhook without creds = %d, want 204", got) + } + if got := get(h, "/healthz", "", "").Code; got != http.StatusOK { + t.Errorf("GET /healthz without creds = %d, want 200", got) + } +} + +// seedList inserts the deterministic fixed-date dataset used by the +// traversals endpoint tests (same shape as the store package's seed). +func seedList(t *testing.T, st *store.Store) { + t.Helper() + ctx := context.Background() + base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + rows := []struct { + id, domain, qtype, ip, status string + day int + durMS int64 + }{ + {"a1", "example.com", "A", "203.0.113.1", store.StatusComplete, 0, 100}, + {"a2", "example.com", "AAAA", "203.0.113.1", store.StatusComplete, 0, 200}, + {"a3", "sub.example.com", "A", "203.0.113.2", store.StatusError, 1, 300}, + {"a4", "other.net", "MX", "203.0.113.3", store.StatusComplete, 1, 400}, + {"a5", "other.net", "A", "203.0.113.1", store.StatusComplete, 2, 500}, + {"a6", "under_score.org", "A", "203.0.113.4", store.StatusComplete, 2, 600}, + } + for i, r := range rows { + started := base.AddDate(0, 0, r.day).Add(time.Duration(i) * time.Minute) + ev := store.CompleteEvent{ + ID: r.id, Domain: r.domain, QueryType: r.qtype, ClientIP: r.ip, + StartedAt: started, DoneAt: started.Add(time.Duration(r.durMS) * time.Millisecond), + DurationMS: r.durMS, Status: r.status, ResultCount: 1, + Summary: json.RawMessage(`{"answers":[{"probability":1}]}`), + } + if r.status == store.StatusError { + ev.Error = "lookup failed" + } + if err := st.RecordComplete(ctx, ev); err != nil { + t.Fatalf("seed %s: %v", r.id, err) + } + } + if err := st.RecordStart(ctx, store.StartEvent{ + ID: "a7", Domain: "running.io", QueryType: "A", ClientIP: "203.0.113.5", + StartedAt: base.AddDate(0, 0, 2).Add(time.Hour), + }); err != nil { + t.Fatalf("seed a7: %v", err) + } +} + +type listResponse struct { + Total int `json:"total"` + Items []struct { + ID string `json:"id"` + Domain string `json:"domain"` + QueryType string `json:"query_type"` + ClientIP string `json:"client_ip"` + StartedAt string `json:"started_at"` + DoneAt string `json:"done_at"` + DurationMS *int64 `json:"duration_ms"` + Status string `json:"status"` + Error string `json:"error"` + ResultCount *int `json:"result_count"` + Summary json.RawMessage `json:"summary"` + ReceivedAt string `json:"received_at"` + } `json:"items"` +} + +func listGET(t *testing.T, h http.Handler, query string) listResponse { + t.Helper() + w := get(h, "/admin/api/traversals"+query, testAdminUser, testAdminPass) + if w.Code != http.StatusOK { + t.Fatalf("GET %s = %d (%s)", query, w.Code, w.Body) + } + var resp listResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode %s: %v", query, err) + } + return resp +} + +func TestAdminTraversalsEndpoint(t *testing.T) { + h, st := newTestHandler(t, "") + seedList(t, st) + + // Default page: everything, newest first. + resp := listGET(t, h, "") + if resp.Total != 7 || len(resp.Items) != 7 { + t.Fatalf("total = %d, len = %d, want 7 and 7", resp.Total, len(resp.Items)) + } + first := resp.Items[0] + if first.ID != "a7" || first.Status != store.StatusRunning { + t.Errorf("first item = %s/%s, want a7/running", first.ID, first.Status) + } + if first.DurationMS != nil || first.DoneAt != "" || len(first.Summary) != 0 { + t.Errorf("running item has completion fields: %+v", first) + } + if first.StartedAt != "2026-07-03T01:00:00Z" { + t.Errorf("started_at = %q, want 2026-07-03T01:00:00Z", first.StartedAt) + } + if first.ReceivedAt == "" { + t.Error("received_at empty") + } + + // Completed rows carry duration, result count, and raw summary JSON. + last := resp.Items[6] + if last.ID != "a1" || last.DurationMS == nil || *last.DurationMS != 100 || + last.ResultCount == nil || *last.ResultCount != 1 { + t.Errorf("oldest item = %+v, want a1 with duration 100 and 1 result", last) + } + if string(last.Summary) != `{"answers":[{"probability":1}]}` { + t.Errorf("summary = %s", last.Summary) + } + + // Pagination. + resp = listGET(t, h, "?limit=3&offset=3") + if resp.Total != 7 || len(resp.Items) != 3 { + t.Fatalf("page 2 total = %d, len = %d, want 7 and 3", resp.Total, len(resp.Items)) + } + if resp.Items[0].ID != "a4" || resp.Items[1].ID != "a3" || resp.Items[2].ID != "a2" { + t.Errorf("page 2 order = %s,%s,%s, want a4,a3,a2", + resp.Items[0].ID, resp.Items[1].ID, resp.Items[2].ID) + } + + // Filters. + if resp := listGET(t, h, "?domain=example"); resp.Total != 3 { + t.Errorf("domain filter total = %d, want 3", resp.Total) + } + resp = listGET(t, h, "?status=error") + if resp.Total != 1 || resp.Items[0].ID != "a3" || resp.Items[0].Error != "lookup failed" { + t.Errorf("status filter = %+v, want a3 with error", resp) + } + resp = listGET(t, h, "?from=2026-07-02T00:00:00Z&to=2026-07-02T23:59:59Z") + if resp.Total != 2 { + t.Errorf("time window total = %d, want 2", resp.Total) + } + + // Limit above the cap succeeds and is clamped rather than rejected. + if resp := listGET(t, h, "?limit=1000"); resp.Total != 7 { + t.Errorf("clamped limit total = %d, want 7", resp.Total) + } +} + +func TestAdminTraversalsBadParams(t *testing.T) { + h, _ := newTestHandler(t, "") + for _, q := range []string{"?limit=abc", "?limit=0", "?offset=-1", "?from=notatime", "?to=2026-13-99"} { + if got := get(h, "/admin/api/traversals"+q, testAdminUser, testAdminPass).Code; got != http.StatusBadRequest { + t.Errorf("GET %s = %d, want 400", q, got) + } + } + if got := get(h, "/admin/api/stats?days=x", testAdminUser, testAdminPass).Code; got != http.StatusBadRequest { + t.Errorf("GET stats?days=x = %d, want 400", got) + } +} + +type statsBody struct { + Days int `json:"days"` + Totals struct { + AllTime int `json:"all_time"` + Last24h int `json:"last_24h"` + Last7d int `json:"last_7d"` + DistinctDomains int `json:"distinct_domains"` + DistinctClients int `json:"distinct_clients"` + ErrorRate float64 `json:"error_rate"` + } `json:"totals"` + PerDay []struct { + Day string `json:"day"` + Total int `json:"total"` + Errors int `json:"errors"` + } `json:"per_day"` + TopDomains []struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"top_domains"` + QueryTypes []struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"query_types"` + Statuses []struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"statuses"` + Durations struct { + Count int `json:"count"` + AvgMS float64 `json:"avg_ms"` + P50MS int64 `json:"p50_ms"` + P95MS int64 `json:"p95_ms"` + } `json:"durations"` + TopClients []struct { + Name string `json:"name"` + Count int `json:"count"` + } `json:"top_clients"` +} + +func statsGET(t *testing.T, h http.Handler, query string) statsBody { + t.Helper() + w := get(h, "/admin/api/stats"+query, testAdminUser, testAdminPass) + if w.Code != http.StatusOK { + t.Fatalf("GET stats%s = %d (%s)", query, w.Code, w.Body) + } + var resp statsBody + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode stats: %v", err) + } + return resp +} + +func TestAdminStatsEndpoint(t *testing.T) { + h, st := newTestHandler(t, "") + ctx := context.Background() + now := time.Now().UTC() + + complete := func(id, domain, qtype, ip, status string, ago time.Duration, durMS int64) { + t.Helper() + ev := store.CompleteEvent{ + ID: id, Domain: domain, QueryType: qtype, ClientIP: ip, + StartedAt: now.Add(-ago), DoneAt: now.Add(-ago).Add(time.Duration(durMS) * time.Millisecond), + DurationMS: durMS, Status: status, ResultCount: 1, + } + if status == store.StatusError { + ev.Error = "boom" + } + if err := st.RecordComplete(ctx, ev); err != nil { + t.Fatalf("seed %s: %v", id, err) + } + } + complete("r1", "a.com", "A", "203.0.113.1", store.StatusComplete, time.Hour, 100) + complete("r2", "a.com", "A", "203.0.113.2", store.StatusError, 2*time.Hour, 200) + complete("r3", "b.net", "A", "203.0.113.1", store.StatusComplete, 30*time.Hour, 300) + complete("r4", "c.org", "MX", "203.0.113.3", store.StatusComplete, 8*24*time.Hour, 400) + if err := st.RecordStart(ctx, store.StartEvent{ + ID: "r5", Domain: "a.com", QueryType: "A", ClientIP: "203.0.113.1", + StartedAt: now.Add(-time.Hour), + }); err != nil { + t.Fatalf("seed r5: %v", err) + } + + s := statsGET(t, h, "?days=30") + if s.Days != 30 { + t.Errorf("days = %d, want 30", s.Days) + } + tot := s.Totals + if tot.AllTime != 5 || tot.Last24h != 3 || tot.Last7d != 4 || + tot.DistinctDomains != 3 || tot.DistinctClients != 3 { + t.Errorf("totals = %+v, want all 5, 24h 3, 7d 4, domains 3, clients 3", tot) + } + if tot.ErrorRate != 0.2 { + t.Errorf("error_rate = %v, want 0.2", tot.ErrorRate) + } + + var perDayTotal, perDayErrors int + for _, d := range s.PerDay { + perDayTotal += d.Total + perDayErrors += d.Errors + } + if perDayTotal != 5 || perDayErrors != 1 { + t.Errorf("per_day sums = %d/%d, want 5/1 (%+v)", perDayTotal, perDayErrors, s.PerDay) + } + + if len(s.TopDomains) != 3 || s.TopDomains[0].Name != "a.com" || s.TopDomains[0].Count != 3 { + t.Errorf("top_domains = %+v, want a.com x3 leading 3", s.TopDomains) + } + if len(s.QueryTypes) != 2 || s.QueryTypes[0].Name != "A" || s.QueryTypes[0].Count != 4 || + s.QueryTypes[1].Name != "MX" || s.QueryTypes[1].Count != 1 { + t.Errorf("query_types = %+v, want A x4, MX x1", s.QueryTypes) + } + if len(s.Statuses) != 3 || s.Statuses[0].Name != store.StatusComplete || s.Statuses[0].Count != 3 { + t.Errorf("statuses = %+v, want complete x3 leading", s.Statuses) + } + d := s.Durations + if d.Count != 4 || d.AvgMS != 250 || d.P50MS != 200 || d.P95MS != 400 { + t.Errorf("durations = %+v, want count 4 avg 250 p50 200 p95 400", d) + } + if len(s.TopClients) != 3 || s.TopClients[0].Name != "203.0.113.1" || s.TopClients[0].Count != 3 { + t.Errorf("top_clients = %+v, want 203.0.113.1 x3 leading 3", s.TopClients) + } + + // A 7-day window drops r4 from the windowed aggregates but not from the + // fixed totals. + s = statsGET(t, h, "?days=7") + if s.Totals.AllTime != 5 { + t.Errorf("7d all_time = %d, want 5", s.Totals.AllTime) + } + if s.Durations.Count != 3 || s.Durations.AvgMS != 200 { + t.Errorf("7d durations = %+v, want count 3 avg 200", s.Durations) + } + if len(s.QueryTypes) != 1 || s.QueryTypes[0].Count != 4 { + t.Errorf("7d query_types = %+v, want A x4 only", s.QueryTypes) + } + + // days above the cap clamps to 365. + if s := statsGET(t, h, "?days=9999"); s.Days != 365 { + t.Errorf("days = %d, want 365", s.Days) + } + // default is 30. + if s := statsGET(t, h, ""); s.Days != 30 { + t.Errorf("default days = %d, want 30", s.Days) + } +} + +func TestAdminStatsEmptyStore(t *testing.T) { + h, _ := newTestHandler(t, "") + s := statsGET(t, h, "") + if s.Totals.AllTime != 0 || s.Totals.ErrorRate != 0 { + t.Errorf("empty totals = %+v, want zeros", s.Totals) + } + // Arrays must be present (possibly empty), never null. + w := get(h, "/admin/api/stats", testAdminUser, testAdminPass) + body := w.Body.String() + for _, key := range []string{`"per_day":[]`, `"top_domains":[]`, `"query_types":[]`, `"statuses":[]`, `"top_clients":[]`} { + if !strings.Contains(body, key) { + t.Errorf("stats body missing %s: %s", key, body) + } + } +} + +// TestStaticAdmin_Markup asserts the admin page ships the stat cards, +// charts, log table, and controls the API is built for. +func TestStaticAdmin_Markup(t *testing.T) { + h, _ := newTestHandler(t, "") + w := get(h, "/admin", testAdminUser, testAdminPass) + if w.Code != http.StatusOK { + t.Fatalf("GET /admin = %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } + page := w.Body.String() + for _, want := range []string{ + `id="statAllTime"`, `id="statLast24h"`, `id="statLast7d"`, + `id="statDomains"`, `id="statClients"`, `id="statErrorRate"`, + `id="perDayChart"`, `id="statusChart"`, `id="domainsChart"`, `id="typesChart"`, + `cdn.jsdelivr.net/npm/chart.js@4`, // Chart.js 4, lazy-loaded + `id="logTable"`, `id="logBody"`, + `ReceivedDomainTypeStatusDurationClient IPResults`, + `id="filterDomain"`, `id="filterStatus"`, `id="filterFrom"`, `id="filterTo"`, `id="applyFilters"`, + `id="prevPage"`, `id="nextPage"`, `id="pageInfo"`, + `id="autoRefresh"`, `id="daysSelect"`, + `/admin/api/stats`, `/admin/api/traversals`, + } { + if !strings.Contains(page, want) { + t.Errorf("admin.html missing %q", want) + } + } +} + +// TestStaticAdmin_ScriptSyntax runs node --check over the inline admin +// script so syntax errors fail in CI, not in the browser. +func TestStaticAdmin_ScriptSyntax(t *testing.T) { + nodeBin, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed") + } + page := string(adminHTML) + start := strings.Index(page, "") + if start < 0 || end < 0 || end < start { + t.Fatal("admin.html has no inline script") + } + src := page[start+len(" + +