feat(receiver): ingest server and admin dashboard

POST /webhook (bearer-token auth, strict validation, 1MB cap) and
/healthz stay open; everything under /admin requires basic auth
(RECEIVER_ADMIN_USER/PASSWORD, refuses to start without a password,
constant-time compares). Admin JSON APIs for the traversal log
(filters, pagination) and stats, plus an embedded dashboard: stat
cards, four Chart.js charts (lazy CDN load with graceful degradation),
filterable log with expandable summaries, auto-refresh. Fetches
resolve against location.origin so credentialed bookmark URLs work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gary Hansen
2026-07-08 02:43:51 +10:00
co-authored by Claude Fable 5
parent e8f56a1ef4
commit beb595442f
6 changed files with 1765 additions and 0 deletions
+280
View File
@@ -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)
}