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>
281 lines
8.0 KiB
Go
281 lines
8.0 KiB
Go
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)
|
|
}
|