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) }