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:
co-authored by
Claude Fable 5
parent
e8f56a1ef4
commit
beb595442f
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user