feat/telemetry-receiver #29
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`,
|
||||||
|
`<th>Received</th><th>Domain</th><th>Type</th><th>Status</th><th>Duration</th><th>Client IP</th><th>Results</th>`,
|
||||||
|
`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, "<script>")
|
||||||
|
end := strings.LastIndex(page, "</script>")
|
||||||
|
if start < 0 || end < 0 || end < start {
|
||||||
|
t.Fatal("admin.html has no inline script")
|
||||||
|
}
|
||||||
|
src := page[start+len("<script>") : end]
|
||||||
|
f := filepath.Join(t.TempDir(), "admin.js")
|
||||||
|
if err := os.WriteFile(f, []byte(src), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(nodeBin, "--check", f).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("node --check: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Package server exposes the webhook telemetry receiver over HTTP:
|
||||||
|
// - POST /webhook — ingest start/complete events from the main app
|
||||||
|
// - GET /healthz — health check
|
||||||
|
// - GET /admin — basic-auth-protected admin UI and JSON API
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBodyBytes caps webhook request bodies, matching the sender-side API
|
||||||
|
// request cap.
|
||||||
|
const maxBodyBytes = 1 << 20
|
||||||
|
|
||||||
|
// Server is the receiver HTTP server.
|
||||||
|
type Server struct {
|
||||||
|
addr string
|
||||||
|
version string
|
||||||
|
token string
|
||||||
|
adminUser string
|
||||||
|
adminPass string
|
||||||
|
st *store.Store
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Server that listens on addr and writes events to st.
|
||||||
|
func New(addr string, st *store.Store) *Server {
|
||||||
|
return &Server{addr: addr, version: "dev", st: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVersion records the build version reported by GET /healthz.
|
||||||
|
// Call before Start; empty values are ignored.
|
||||||
|
func (s *Server) SetVersion(v string) {
|
||||||
|
if v != "" {
|
||||||
|
s.version = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIngestToken enables bearer authentication on POST /webhook. Empty
|
||||||
|
// leaves the endpoint open. Call before Start.
|
||||||
|
func (s *Server) SetIngestToken(t string) { s.token = t }
|
||||||
|
|
||||||
|
// SetAdminAuth sets the basic-auth credentials for /admin. Call before
|
||||||
|
// Start; Start refuses to run without a password so the admin interface
|
||||||
|
// can never be exposed unprotected.
|
||||||
|
func (s *Server) SetAdminAuth(user, pass string) {
|
||||||
|
s.adminUser = user
|
||||||
|
s.adminPass = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening. Call Shutdown to stop gracefully.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
if s.adminPass == "" {
|
||||||
|
return errors.New("admin password not set (RECEIVER_ADMIN_PASSWORD): refusing to expose /admin unprotected")
|
||||||
|
}
|
||||||
|
s.srv = &http.Server{
|
||||||
|
Addr: s.addr,
|
||||||
|
Handler: newHandler(s.st, s.version, s.token, s.adminUser, s.adminPass),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", s.addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen %s: %w", s.addr, err)
|
||||||
|
}
|
||||||
|
s.addr = ln.Addr().String()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("receiver server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the address the server is listening on. Valid after Start.
|
||||||
|
func (s *Server) Addr() string { return s.addr }
|
||||||
|
|
||||||
|
// Shutdown gracefully stops the server, waiting up to timeout for in-flight
|
||||||
|
// requests to complete.
|
||||||
|
func (s *Server) Shutdown(timeout time.Duration) error {
|
||||||
|
if s.srv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
return s.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handler routes receiver HTTP requests.
|
||||||
|
type handler struct {
|
||||||
|
st *store.Store
|
||||||
|
version string
|
||||||
|
token string
|
||||||
|
adminUser string
|
||||||
|
adminPass string
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHandler builds the receiver's HTTP handler. token "" leaves /webhook
|
||||||
|
// open; adminPass "" leaves /admin permanently locked (every request 401s).
|
||||||
|
func newHandler(st *store.Store, version, token, adminUser, adminPass string) http.Handler {
|
||||||
|
h := &handler{st: st, version: version, token: token,
|
||||||
|
adminUser: adminUser, adminPass: adminPass, mux: http.NewServeMux()}
|
||||||
|
h.mux.HandleFunc("POST /webhook", h.ingest)
|
||||||
|
h.mux.HandleFunc("GET /healthz", h.healthz)
|
||||||
|
|
||||||
|
// The whole /admin subtree sits behind basic auth, including paths the
|
||||||
|
// inner mux will 404.
|
||||||
|
admin := http.NewServeMux()
|
||||||
|
admin.HandleFunc("GET /admin", h.adminPage)
|
||||||
|
admin.HandleFunc("GET /admin/{$}", h.adminPage)
|
||||||
|
admin.HandleFunc("GET /admin/api/traversals", h.adminTraversals)
|
||||||
|
admin.HandleFunc("GET /admin/api/stats", h.adminStats)
|
||||||
|
protected := h.requireAdmin(admin)
|
||||||
|
h.mux.Handle("/admin", protected)
|
||||||
|
h.mux.Handle("/admin/", protected)
|
||||||
|
return h.mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthz handles GET /healthz.
|
||||||
|
func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": h.version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingest handles POST /webhook: it routes on the payload's "event" field
|
||||||
|
// and upserts the event into the store. The payload structs live in the
|
||||||
|
// store package and mirror web/api/webhook.go exactly.
|
||||||
|
func (h *handler) ingest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.authorized(r) {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing or invalid bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge,
|
||||||
|
fmt.Sprintf("body exceeds %d bytes", tooLarge.Limit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &probe); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch probe.Event {
|
||||||
|
case "start":
|
||||||
|
var ev store.StartEvent
|
||||||
|
if err := json.Unmarshal(body, &ev); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid start event: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.st.RecordStart(r.Context(), ev)
|
||||||
|
case "complete":
|
||||||
|
var ev store.CompleteEvent
|
||||||
|
if err := json.Unmarshal(body, &ev); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid complete event: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.st.RecordComplete(r.Context(), ev)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown event %q", probe.Event))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("receiver: store %s event: %v", probe.Event, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "store event failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorized checks the bearer token when one is configured.
|
||||||
|
func (h *handler) authorized(r *http.Request) bool {
|
||||||
|
if h.token == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const prefix = "Bearer "
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return secretEqual(strings.TrimPrefix(auth, prefix), h.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON encodes v as JSON and writes it to w with the given status code.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes a JSON error response.
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T, token string) (http.Handler, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "receiver.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenSQLite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return newHandler(st, "test-version", token, testAdminUser, testAdminPass), st
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(h http.Handler, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
const startJSON = `{"event":"start","id":"job-1","domain":"example.com","query_type":"A",` +
|
||||||
|
`"all_roots":true,"client_ip":"203.0.113.9","started_at":"2026-07-01T10:00:00Z"}`
|
||||||
|
|
||||||
|
const completeJSON = `{"event":"complete","id":"job-1","domain":"example.com","query_type":"A",` +
|
||||||
|
`"client_ip":"203.0.113.9","started_at":"2026-07-01T10:00:00Z","done_at":"2026-07-01T10:00:03Z",` +
|
||||||
|
`"duration_ms":3000,"status":"complete","result_count":2,` +
|
||||||
|
`"summary":{"answers":[{"probability":1,"records":["example.com 300 IN A 192.0.2.1"]}]}}`
|
||||||
|
|
||||||
|
func TestIngestAuthMatrix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
authHeader string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"no token configured, no header", "", "", http.StatusNoContent},
|
||||||
|
{"no token configured, stray header", "", "Bearer whatever", http.StatusNoContent},
|
||||||
|
{"token configured, missing header", "s3cret", "", http.StatusUnauthorized},
|
||||||
|
{"token configured, wrong scheme", "s3cret", "Basic s3cret", http.StatusUnauthorized},
|
||||||
|
{"token configured, wrong token", "s3cret", "Bearer nope", http.StatusUnauthorized},
|
||||||
|
{"token configured, correct token", "s3cret", "Bearer s3cret", http.StatusNoContent},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t, tc.token)
|
||||||
|
headers := map[string]string{}
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
headers["Authorization"] = tc.authHeader
|
||||||
|
}
|
||||||
|
if got := post(h, startJSON, headers).Code; got != tc.want {
|
||||||
|
t.Errorf("status = %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestStartEvent(t *testing.T) {
|
||||||
|
h, st := newTestHandler(t, "")
|
||||||
|
|
||||||
|
w := post(h, startJSON, nil)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204 (%s)", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTraversals: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("total = %d, want 1", total)
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
if row.ID != "job-1" || row.Domain != "example.com" || row.QueryType != "A" ||
|
||||||
|
!row.AllRoots || row.ClientIP != "203.0.113.9" || row.Status != store.StatusRunning {
|
||||||
|
t.Errorf("row = %+v", row)
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
if !row.StartedAt.Equal(want) {
|
||||||
|
t.Errorf("StartedAt = %v, want %v", row.StartedAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestCompleteEvent(t *testing.T) {
|
||||||
|
h, st := newTestHandler(t, "")
|
||||||
|
|
||||||
|
if w := post(h, startJSON, nil); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("start status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := post(h, completeJSON, nil); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("complete status = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTraversals: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("total = %d, want 1", total)
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
if row.Status != store.StatusComplete {
|
||||||
|
t.Errorf("Status = %q, want complete", row.Status)
|
||||||
|
}
|
||||||
|
if row.DurationMS == nil || *row.DurationMS != 3000 {
|
||||||
|
t.Errorf("DurationMS = %v, want 3000", row.DurationMS)
|
||||||
|
}
|
||||||
|
if row.ResultCount == nil || *row.ResultCount != 2 {
|
||||||
|
t.Errorf("ResultCount = %v, want 2", row.ResultCount)
|
||||||
|
}
|
||||||
|
if !strings.Contains(row.Summary, "192.0.2.1") {
|
||||||
|
t.Errorf("Summary = %q, want raw summary JSON", row.Summary)
|
||||||
|
}
|
||||||
|
if !row.AllRoots {
|
||||||
|
t.Error("AllRoots lost after complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestRejectsBadInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"malformed JSON", `{"event":`},
|
||||||
|
{"unknown event", `{"event":"pause","id":"x"}`},
|
||||||
|
{"empty event", `{"id":"x"}`},
|
||||||
|
{"wrong type for field", `{"event":"start","id":42}`},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h, st := newTestHandler(t, "")
|
||||||
|
if got := post(h, tc.body, nil).Code; got != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", got)
|
||||||
|
}
|
||||||
|
_, total, err := st.ListTraversals(context.Background(), store.ListFilter{}, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTraversals: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Errorf("stored %d rows from rejected input", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestBodyCap(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t, "")
|
||||||
|
big := `{"event":"start","id":"x","domain":"` + strings.Repeat("a", maxBodyBytes) + `"}`
|
||||||
|
if got := post(h, big, nil).Code; got != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status = %d, want 413", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t, "s3cret")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
// healthz stays open even when an ingest token is configured.
|
||||||
|
if !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"version":"test-version"`) {
|
||||||
|
t.Errorf("body = %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerStartShutdown(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)
|
||||||
|
srv.SetVersion("v-test")
|
||||||
|
srv.SetAdminAuth(testAdminUser, testAdminPass)
|
||||||
|
if err := srv.Start(); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Shutdown(2 * time.Second)
|
||||||
|
|
||||||
|
resp, err := http.Get("http://" + srv.Addr() + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /healthz: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("healthz status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if err := srv.Shutdown(2 * time.Second); err != nil {
|
||||||
|
t.Errorf("Shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ExploreDNS Receiver — Admin</title>
|
||||||
|
<style>
|
||||||
|
/* ── Design tokens (mirrors web/api/static/index.html) ─────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg-surface: #1a1d27;
|
||||||
|
--bg-card: #21242f;
|
||||||
|
--bg-input: #2a2d3a;
|
||||||
|
--border: #353847;
|
||||||
|
--border-focus: #4a7cf6;
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-muted: #8b8fa8;
|
||||||
|
--text-dim: #5c6070;
|
||||||
|
--primary: #4a7cf6;
|
||||||
|
--primary-hover:#3a6ce6;
|
||||||
|
--success: #3ecf8e;
|
||||||
|
--warning: #f5a623;
|
||||||
|
--danger: #f04438;
|
||||||
|
--info: #38bdf8;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 5px;
|
||||||
|
--shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ─────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 15px; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
button { cursor: pointer; font: inherit; }
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||||
|
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
|
header {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.logo-icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.logo-sub { color: var(--text-muted); font-weight: 500; font-size: 0.85rem; }
|
||||||
|
.header-controls { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); }
|
||||||
|
.header-controls label { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; }
|
||||||
|
main { flex: 1; padding: 2rem 1.5rem; max-width: 1100px; margin: 0 auto; width: 100%; }
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ─────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat cards ────────────────────────────────────────────────── */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.stat-card { padding: 0.9rem 1.1rem; }
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.stat-value { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Charts ────────────────────────────────────────────────────── */
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.chart-card.wide { grid-column: 1 / -1; }
|
||||||
|
.chart-card canvas { max-height: 260px; }
|
||||||
|
|
||||||
|
/* ── Filters ───────────────────────────────────────────────────── */
|
||||||
|
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||||
|
.filters input, .filters select, .header-controls select {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.filters input:focus, .filters select:focus { outline: none; border-color: var(--border-focus); }
|
||||||
|
.filters button, .pager button {
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.filters button:hover, .pager button:hover:not(:disabled) { background: var(--primary-hover); }
|
||||||
|
.pager button:disabled { background: var(--bg-input); color: var(--text-dim); cursor: default; }
|
||||||
|
|
||||||
|
/* ── Log table ─────────────────────────────────────────────────── */
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
th {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
tr.log-row { cursor: pointer; }
|
||||||
|
tr.log-row:hover td { background: var(--bg-input); }
|
||||||
|
td.mono { font-family: var(--font-mono); font-size: 0.8rem; }
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.chip-complete { background: rgba(62,207,142,0.15); color: var(--success); }
|
||||||
|
.chip-error { background: rgba(240,68,56,0.15); color: var(--danger); }
|
||||||
|
.chip-running { background: rgba(56,189,248,0.15); color: var(--info); }
|
||||||
|
tr.detail-row td { background: var(--bg-surface); padding: 0.75rem 1rem; }
|
||||||
|
tr.detail-row pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty { color: var(--text-dim); text-align: center; padding: 1.5rem 0; }
|
||||||
|
|
||||||
|
#errorBar {
|
||||||
|
display: none;
|
||||||
|
background: rgba(240,68,56,0.12);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<div class="logo"><div class="logo-icon">📡</div>ExploreDNS <span class="logo-sub">receiver admin</span></div>
|
||||||
|
<div class="header-controls">
|
||||||
|
<label><input type="checkbox" id="autoRefresh"> auto-refresh 30s</label>
|
||||||
|
<select id="daysSelect" title="chart window">
|
||||||
|
<option value="7">7 days</option>
|
||||||
|
<option value="30" selected>30 days</option>
|
||||||
|
<option value="90">90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="errorBar"></div>
|
||||||
|
|
||||||
|
<section class="stat-grid" id="statCards">
|
||||||
|
<div class="card stat-card"><div class="stat-label">All time</div><div class="stat-value" id="statAllTime">–</div></div>
|
||||||
|
<div class="card stat-card"><div class="stat-label">Last 24 h</div><div class="stat-value" id="statLast24h">–</div></div>
|
||||||
|
<div class="card stat-card"><div class="stat-label">Last 7 d</div><div class="stat-value" id="statLast7d">–</div></div>
|
||||||
|
<div class="card stat-card"><div class="stat-label">Domains</div><div class="stat-value" id="statDomains">–</div></div>
|
||||||
|
<div class="card stat-card"><div class="stat-label">Clients</div><div class="stat-value" id="statClients">–</div></div>
|
||||||
|
<div class="card stat-card"><div class="stat-label">Error rate</div><div class="stat-value" id="statErrorRate">–</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="chart-grid">
|
||||||
|
<div class="card chart-card wide"><div class="card-title">Traversals per day</div><canvas id="perDayChart"></canvas></div>
|
||||||
|
<div class="card chart-card"><div class="card-title">Status</div><canvas id="statusChart"></canvas></div>
|
||||||
|
<div class="card chart-card"><div class="card-title">Top domains</div><canvas id="domainsChart"></canvas></div>
|
||||||
|
<div class="card chart-card"><div class="card-title">Query types</div><canvas id="typesChart"></canvas></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card log-card">
|
||||||
|
<div class="card-title">Log</div>
|
||||||
|
<div class="filters">
|
||||||
|
<input id="filterDomain" placeholder="domain contains…">
|
||||||
|
<select id="filterStatus">
|
||||||
|
<option value="">all statuses</option>
|
||||||
|
<option value="running">running</option>
|
||||||
|
<option value="complete">complete</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
<input type="datetime-local" id="filterFrom" title="from">
|
||||||
|
<input type="datetime-local" id="filterTo" title="to">
|
||||||
|
<button id="applyFilters">Apply</button>
|
||||||
|
</div>
|
||||||
|
<table id="logTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Received</th><th>Domain</th><th>Type</th><th>Status</th><th>Duration</th><th>Client IP</th><th>Results</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="logBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pager">
|
||||||
|
<button id="prevPage">‹ Prev</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button id="nextPage">Next ›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer>ExploreDNS webhook receiver</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const CHART_SRC = 'https://cdn.jsdelivr.net/npm/chart.js@4';
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let page = 0;
|
||||||
|
let total = 0;
|
||||||
|
const charts = {};
|
||||||
|
let chartLoad = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"']/g,
|
||||||
|
(c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
|
// Chart.js is lazy-loaded on first use so the page renders without the CDN.
|
||||||
|
function loadChartJS() {
|
||||||
|
if (window.Chart) return Promise.resolve();
|
||||||
|
if (!chartLoad) {
|
||||||
|
chartLoad = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = CHART_SRC;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => { chartLoad = null; reject(new Error('failed to load Chart.js')); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return chartLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
// Resolve against origin (never contains userinfo) so the dashboard
|
||||||
|
// still works when opened via a http://user:pass@host bookmark —
|
||||||
|
// fetch() rejects relative URLs on pages with credentialed base URLs.
|
||||||
|
const abs = new URL(url, window.location.origin).toString();
|
||||||
|
const resp = await fetch(abs, { headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!resp.ok) throw new Error(url + ': HTTP ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(err) {
|
||||||
|
const bar = $('errorBar');
|
||||||
|
bar.textContent = String(err);
|
||||||
|
bar.style.display = 'block';
|
||||||
|
}
|
||||||
|
function clearError() { $('errorBar').style.display = 'none'; }
|
||||||
|
|
||||||
|
const fmtDuration = (ms) => ms == null ? '—'
|
||||||
|
: (ms >= 1000 ? (ms / 1000).toFixed(2) + ' s' : ms + ' ms');
|
||||||
|
const fmtLocal = (iso) => iso ? new Date(iso).toLocaleString() : '—';
|
||||||
|
|
||||||
|
function cssVar(name) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertChart(id, cfg) {
|
||||||
|
if (charts[id]) {
|
||||||
|
charts[id].data = cfg.data;
|
||||||
|
charts[id].update();
|
||||||
|
} else {
|
||||||
|
charts[id] = new Chart($(id), cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCharts(s) {
|
||||||
|
Chart.defaults.color = cssVar('--text-muted');
|
||||||
|
Chart.defaults.borderColor = cssVar('--border');
|
||||||
|
const statusColor = { complete: cssVar('--success'), error: cssVar('--danger'), running: cssVar('--info') };
|
||||||
|
upsertChart('perDayChart', {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: s.per_day.map((d) => d.day),
|
||||||
|
datasets: [
|
||||||
|
{ label: 'traversals', data: s.per_day.map((d) => d.total),
|
||||||
|
borderColor: cssVar('--primary'), backgroundColor: 'rgba(74,124,246,0.15)', fill: true, tension: 0.25 },
|
||||||
|
{ label: 'errors', data: s.per_day.map((d) => d.errors),
|
||||||
|
borderColor: cssVar('--danger'), backgroundColor: 'transparent', tension: 0.25 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: { scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
||||||
|
});
|
||||||
|
upsertChart('statusChart', {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: s.statuses.map((x) => x.name),
|
||||||
|
datasets: [{
|
||||||
|
data: s.statuses.map((x) => x.count),
|
||||||
|
backgroundColor: s.statuses.map((x) => statusColor[x.name] || cssVar('--warning')),
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upsertChart('domainsChart', {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: s.top_domains.map((x) => x.name),
|
||||||
|
datasets: [{ label: 'traversals', data: s.top_domains.map((x) => x.count), backgroundColor: cssVar('--primary') }],
|
||||||
|
},
|
||||||
|
options: { indexAxis: 'y', plugins: { legend: { display: false } },
|
||||||
|
scales: { x: { beginAtZero: true, ticks: { precision: 0 } } } },
|
||||||
|
});
|
||||||
|
upsertChart('typesChart', {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: s.query_types.map((x) => x.name),
|
||||||
|
datasets: [{ label: 'traversals', data: s.query_types.map((x) => x.count), backgroundColor: cssVar('--info') }],
|
||||||
|
},
|
||||||
|
options: { plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
const s = await fetchJSON('/admin/api/stats?days=' + encodeURIComponent($('daysSelect').value));
|
||||||
|
$('statAllTime').textContent = s.totals.all_time;
|
||||||
|
$('statLast24h').textContent = s.totals.last_24h;
|
||||||
|
$('statLast7d').textContent = s.totals.last_7d;
|
||||||
|
$('statDomains').textContent = s.totals.distinct_domains;
|
||||||
|
$('statClients').textContent = s.totals.distinct_clients;
|
||||||
|
$('statErrorRate').textContent = (s.totals.error_rate * 100).toFixed(1) + '%';
|
||||||
|
await loadChartJS();
|
||||||
|
drawCharts(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterQuery() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', page * PAGE_SIZE);
|
||||||
|
const domain = $('filterDomain').value.trim();
|
||||||
|
if (domain) p.set('domain', domain);
|
||||||
|
const status = $('filterStatus').value;
|
||||||
|
if (status) p.set('status', status);
|
||||||
|
const from = $('filterFrom').value;
|
||||||
|
if (from) p.set('from', new Date(from).toISOString());
|
||||||
|
const to = $('filterTo').value;
|
||||||
|
if (to) p.set('to', new Date(to).toISOString());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailJSON(item) {
|
||||||
|
if (item.summary != null) return JSON.stringify(item.summary, null, 2);
|
||||||
|
return JSON.stringify(item, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLog() {
|
||||||
|
const data = await fetchJSON('/admin/api/traversals?' + filterQuery());
|
||||||
|
total = data.total;
|
||||||
|
const body = $('logBody');
|
||||||
|
body.textContent = '';
|
||||||
|
if (!data.items.length) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = '<td colspan="7" class="empty">no traversals</td>';
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
for (const item of data.items) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'log-row';
|
||||||
|
row.innerHTML =
|
||||||
|
'<td>' + esc(fmtLocal(item.received_at)) + '</td>' +
|
||||||
|
'<td class="mono">' + esc(item.domain) + '</td>' +
|
||||||
|
'<td class="mono">' + esc(item.query_type) + '</td>' +
|
||||||
|
'<td><span class="chip chip-' + esc(item.status) + '">' + esc(item.status) + '</span></td>' +
|
||||||
|
'<td>' + esc(fmtDuration(item.duration_ms)) + '</td>' +
|
||||||
|
'<td class="mono">' + esc(item.client_ip) + '</td>' +
|
||||||
|
'<td>' + esc(item.result_count == null ? '—' : item.result_count) + '</td>';
|
||||||
|
const detail = document.createElement('tr');
|
||||||
|
detail.className = 'detail-row';
|
||||||
|
detail.style.display = 'none';
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
cell.colSpan = 7;
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.textContent = detailJSON(item);
|
||||||
|
cell.appendChild(pre);
|
||||||
|
detail.appendChild(cell);
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
detail.style.display = detail.style.display === 'none' ? '' : 'none';
|
||||||
|
});
|
||||||
|
body.appendChild(row);
|
||||||
|
body.appendChild(detail);
|
||||||
|
}
|
||||||
|
const first = total ? page * PAGE_SIZE + 1 : 0;
|
||||||
|
const last = Math.min((page + 1) * PAGE_SIZE, total);
|
||||||
|
$('pageInfo').textContent = first + '–' + last + ' of ' + total;
|
||||||
|
$('prevPage').disabled = page === 0;
|
||||||
|
$('nextPage').disabled = last >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
clearError();
|
||||||
|
refreshStats().catch(showError);
|
||||||
|
refreshLog().catch(showError);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('daysSelect').addEventListener('change', () => { refreshStats().catch(showError); });
|
||||||
|
$('applyFilters').addEventListener('click', () => { page = 0; refreshLog().catch(showError); });
|
||||||
|
$('filterDomain').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { page = 0; refreshLog().catch(showError); }
|
||||||
|
});
|
||||||
|
$('prevPage').addEventListener('click', () => {
|
||||||
|
if (page > 0) { page--; refreshLog().catch(showError); }
|
||||||
|
});
|
||||||
|
$('nextPage').addEventListener('click', () => {
|
||||||
|
if ((page + 1) * PAGE_SIZE < total) { page++; refreshLog().catch(showError); }
|
||||||
|
});
|
||||||
|
$('autoRefresh').addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
refreshTimer = setInterval(refreshAll, 30000);
|
||||||
|
} else {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user