From 1c53fecde36c06f87d62860e11aee92b9c570d75 Mon Sep 17 00:00:00 2001 From: sbwml Date: Fri, 28 Aug 2026 19:06:24 +0800 Subject: [PATCH 1/5] plugin: add stats_api plugin Signed-off-by: sbwml --- plugin/enabled_plugins.go | 1 + plugin/executable/stats_api/stats_api.go | 726 ++++++++++++++++++ plugin/executable/stats_api/stats_api_test.go | 265 +++++++ 3 files changed, 992 insertions(+) create mode 100644 plugin/executable/stats_api/stats_api.go create mode 100644 plugin/executable/stats_api/stats_api_test.go --- a/plugin/enabled_plugins.go +++ b/plugin/enabled_plugins.go @@ -62,6 +62,7 @@ import ( _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence" _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence/fallback" _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sleep" + _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/stats_api" _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/ttl" // executable and matcher --- /dev/null +++ b/plugin/executable/stats_api/stats_api.go @@ -0,0 +1,726 @@ +/* + * Copyright (C) 2020-2026, IrineSistiana + * + * This file is part of mosdns. + * + * mosdns is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * mosdns is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package stats_api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/IrineSistiana/mosdns/v5/coremain" + "github.com/IrineSistiana/mosdns/v5/pkg/query_context" + "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence" + "github.com/go-chi/chi/v5" + "github.com/miekg/dns" + "go.uber.org/zap" +) + +const PluginType = "stats_api" + +func init() { + coremain.RegNewPluginFunc(PluginType, Init, func() any { return new(Args) }) + sequence.MustRegExecQuickSetup(PluginType, QuickSetup) +} + +var _ sequence.RecursiveExecutable = (*StatsAPI)(nil) + +type Args struct { + Listen string `yaml:"listen"` + Capacity int `yaml:"capacity"` +} + +func (a *Args) init() { + if a.Capacity <= 0 { + a.Capacity = 2000 + } +} + +type AnswerDTO struct { + Type string `json:"type"` + Data string `json:"data"` + TTL uint32 `json:"ttl"` +} + +type LogEntry struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` + ClientIP string `json:"client_ip"` + Domain string `json:"domain"` + QType string `json:"qtype"` + Status string `json:"status"` + IsBlocked bool `json:"is_blocked"` + IsCached bool `json:"is_cached"` + ElapsedMS float64 `json:"elapsed_ms"` + Upstream string `json:"upstream,omitempty"` + Rule string `json:"rule,omitempty"` + Answers []AnswerDTO `json:"answers"` +} + +type RingBuffer struct { + mu sync.RWMutex + buf []LogEntry + capacity int + head int + count int + seqID uint64 +} + +func NewRingBuffer(capacity int) *RingBuffer { + if capacity <= 0 { + capacity = 2000 + } + return &RingBuffer{ + buf: make([]LogEntry, capacity), + capacity: capacity, + } +} + +func (r *RingBuffer) Push(entry LogEntry) { + r.mu.Lock() + defer r.mu.Unlock() + + r.seqID++ + nowSec := time.Now().Unix() + entry.ID = fmt.Sprintf("%d-%d", nowSec, r.seqID) + + r.buf[r.head] = entry + r.head = (r.head + 1) % r.capacity + if r.count < r.capacity { + r.count++ + } +} + +func (r *RingBuffer) Clear() { + r.mu.Lock() + defer r.mu.Unlock() + + r.buf = make([]LogEntry, r.capacity) + r.head = 0 + r.count = 0 + r.seqID = 0 +} + +func (r *RingBuffer) QueryLogs(limit, offset int, search, filter string) (int, []LogEntry) { + r.mu.RLock() + defer r.mu.RUnlock() + + all := make([]LogEntry, 0, r.count) + for i := 0; i < r.count; i++ { + idx := (r.head - 1 - i + r.capacity) % r.capacity + all = append(all, r.buf[idx]) + } + + searchLower := strings.ToLower(strings.TrimSpace(search)) + filterLower := strings.ToLower(strings.TrimSpace(filter)) + + filtered := make([]LogEntry, 0, len(all)) + for _, entry := range all { + if filterLower == "blocked" && !entry.IsBlocked { + continue + } + if filterLower == "cached" && !entry.IsCached { + continue + } + + if searchLower != "" { + if !strings.Contains(strings.ToLower(entry.Domain), searchLower) && + !strings.Contains(strings.ToLower(entry.ClientIP), searchLower) { + continue + } + } + + filtered = append(filtered, entry) + } + + total := len(filtered) + if offset < 0 { + offset = 0 + } + if offset >= total { + return total, []LogEntry{} + } + + end := offset + limit + if end > total { + end = total + } + + return total, filtered[offset:end] +} + +type TopItem struct { + Domain string `json:"domain,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + Count uint64 `json:"count"` +} + +type TopStats struct { + mu sync.RWMutex + topDomains map[string]uint64 + topClients map[string]uint64 + topBlocked map[string]uint64 +} + +func NewTopStats() *TopStats { + return &TopStats{ + topDomains: make(map[string]uint64), + topClients: make(map[string]uint64), + topBlocked: make(map[string]uint64), + } +} + +func (t *TopStats) Record(domain, clientIP string, isBlocked bool) { + t.mu.Lock() + defer t.mu.Unlock() + + if domain != "" { + t.topDomains[domain]++ + } + if clientIP != "" { + t.topClients[clientIP]++ + } + if isBlocked && domain != "" { + t.topBlocked[domain]++ + } +} + +func (t *TopStats) Clear() { + t.mu.Lock() + defer t.mu.Unlock() + + t.topDomains = make(map[string]uint64) + t.topClients = make(map[string]uint64) + t.topBlocked = make(map[string]uint64) +} + +func getSortedTop(m map[string]uint64, isClient bool, limit int) []TopItem { + type pair struct { + key string + count uint64 + } + pairs := make([]pair, 0, len(m)) + for k, v := range m { + pairs = append(pairs, pair{key: k, count: v}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].count == pairs[j].count { + return pairs[i].key < pairs[j].key + } + return pairs[i].count > pairs[j].count + }) + + if limit > len(pairs) { + limit = len(pairs) + } + res := make([]TopItem, 0, limit) + for i := 0; i < limit; i++ { + item := TopItem{Count: pairs[i].count} + if isClient { + item.ClientIP = pairs[i].key + } else { + item.Domain = pairs[i].key + } + res = append(res, item) + } + return res +} + +func (t *TopStats) GetTop(limit int) ([]TopItem, []TopItem, []TopItem) { + t.mu.RLock() + defer t.mu.RUnlock() + + if limit <= 0 { + limit = 10 + } + topDomains := getSortedTop(t.topDomains, false, limit) + topClients := getSortedTop(t.topClients, true, limit) + topBlocked := getSortedTop(t.topBlocked, false, limit) + return topDomains, topClients, topBlocked +} + +type HistoryPoint struct { + Time string `json:"time"` + Total uint64 `json:"total"` + Blocked uint64 `json:"blocked"` + Cached uint64 `json:"cached"` +} + +type HistoryBucket struct { + Total atomic.Uint64 + Blocked atomic.Uint64 + Cached atomic.Uint64 +} + +type HistoryStats struct { + mu sync.RWMutex + points map[int64]*HistoryBucket +} + +func NewHistoryStats() *HistoryStats { + return &HistoryStats{ + points: make(map[int64]*HistoryBucket), + } +} + +func (h *HistoryStats) Record(t time.Time, isBlocked, isCached bool) { + tHour := t.UTC().Truncate(time.Hour).Unix() + + h.mu.RLock() + bucket, ok := h.points[tHour] + h.mu.RUnlock() + + if !ok { + h.mu.Lock() + bucket, ok = h.points[tHour] + if !ok { + bucket = &HistoryBucket{} + h.points[tHour] = bucket + + // Clean up old buckets beyond 48 hours + cutoff := t.UTC().Add(-48 * time.Hour).Unix() + for k := range h.points { + if k < cutoff { + delete(h.points, k) + } + } + } + h.mu.Unlock() + } + + bucket.Total.Add(1) + if isBlocked { + bucket.Blocked.Add(1) + } + if isCached { + bucket.Cached.Add(1) + } +} + +func (h *HistoryStats) GetHistory(numPoints int) []HistoryPoint { + if numPoints <= 0 { + numPoints = 24 + } + now := time.Now().UTC().Truncate(time.Hour) + res := make([]HistoryPoint, 0, numPoints) + + h.mu.RLock() + defer h.mu.RUnlock() + + for i := numPoints - 1; i >= 0; i-- { + slotTime := now.Add(time.Duration(-i) * time.Hour) + slotUnix := slotTime.Unix() + + var total, blocked, cached uint64 + if bucket, ok := h.points[slotUnix]; ok { + total = bucket.Total.Load() + blocked = bucket.Blocked.Load() + cached = bucket.Cached.Load() + } + + res = append(res, HistoryPoint{ + Time: slotTime.Format(time.RFC3339), + Total: total, + Blocked: blocked, + Cached: cached, + }) + } + return res +} + +type StatsAPI struct { + args *Args + logger *zap.Logger + + ringBuffer *RingBuffer + topStats *TopStats + historyStats *HistoryStats + + totalQueries atomic.Uint64 + blockedQueries atomic.Uint64 + cachedQueries atomic.Uint64 + totalLatencyUs atomic.Uint64 + + httpServer *http.Server + closeOnce sync.Once +} + +func Init(bp *coremain.BP, args any) (any, error) { + a := args.(*Args) + s := NewStatsAPI(a, bp.L()) + bp.RegAPI(s.Router()) + return s, nil +} + +func QuickSetup(bq sequence.BQ, s string) (any, error) { + fields := strings.Fields(s) + listen := "" + capacity := 2000 + if len(fields) > 0 { + listen = fields[0] + } + if len(fields) > 1 { + if c, err := strconv.Atoi(fields[1]); err == nil && c > 0 { + capacity = c + } + } + return NewStatsAPI(&Args{Listen: listen, Capacity: capacity}, bq.L()), nil +} + +func NewStatsAPI(args *Args, logger *zap.Logger) *StatsAPI { + args.init() + if logger == nil { + logger = zap.NewNop() + } + s := &StatsAPI{ + args: args, + logger: logger, + ringBuffer: NewRingBuffer(args.Capacity), + topStats: NewTopStats(), + historyStats: NewHistoryStats(), + } + + if len(args.Listen) > 0 { + srv := &http.Server{ + Addr: args.Listen, + Handler: s.Router(), + } + s.httpServer = srv + go func() { + logger.Info("starting stats api http server", zap.String("addr", args.Listen)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("stats api http server error", zap.Error(err)) + } + }() + } + return s +} + +func (s *StatsAPI) Router() *chi.Mux { + r := chi.NewRouter() + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if req.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, req) + }) + }) + + r.Get("/api/v1/stats", s.handleStats) + r.Get("/api/v1/logs", s.handleLogs) + r.Get("/api/v1/top", s.handleTop) + r.Get("/api/v1/history", s.handleHistory) + r.Post("/api/v1/logs/clear", s.handleClearLogs) + r.Post("/api/v1/cache/clear", s.handleClearCache) + + return r +} + +func (s *StatsAPI) handleStats(w http.ResponseWriter, req *http.Request) { + total := s.totalQueries.Load() + blocked := s.blockedQueries.Load() + cached := s.cachedQueries.Load() + latUs := s.totalLatencyUs.Load() + + var blockedPct, cachedPct, avgLat float64 + if total > 0 { + blockedPct = float64(blocked) / float64(total) * 100.0 + cachedPct = float64(cached) / float64(total) * 100.0 + avgLat = (float64(latUs) / float64(total)) / 1000.0 + } + + blockedPct = math.Round(blockedPct*100) / 100 + cachedPct = math.Round(cachedPct*100) / 100 + avgLat = math.Round(avgLat*100) / 100 + + resp := map[string]any{ + "total_queries": total, + "blocked_queries": blocked, + "cached_queries": cached, + "blocked_percentage": blockedPct, + "cached_percentage": cachedPct, + "avg_latency_ms": avgLat, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *StatsAPI) handleLogs(w http.ResponseWriter, req *http.Request) { + q := req.URL.Query() + limitStr := q.Get("limit") + offsetStr := q.Get("offset") + search := q.Get("search") + filter := q.Get("filter") + + limit := 50 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + if limit > 500 { + limit = 500 + } + offset := 0 + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + offset = o + } + + total, items := s.ringBuffer.QueryLogs(limit, offset, search, filter) + if items == nil { + items = []LogEntry{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": total, + "items": items, + }) +} + +func (s *StatsAPI) handleTop(w http.ResponseWriter, req *http.Request) { + limitStr := req.URL.Query().Get("limit") + limit := 10 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + topDomains, topClients, topBlocked := s.topStats.GetTop(limit) + if topDomains == nil { + topDomains = []TopItem{} + } + if topClients == nil { + topClients = []TopItem{} + } + if topBlocked == nil { + topBlocked = []TopItem{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "top_domains": topDomains, + "top_clients": topClients, + "top_blocked": topBlocked, + }) +} + +func (s *StatsAPI) handleHistory(w http.ResponseWriter, req *http.Request) { + pointsStr := req.URL.Query().Get("points") + numPoints := 24 + if p, err := strconv.Atoi(pointsStr); err == nil && p > 0 { + numPoints = p + } + if numPoints > 168 { // Max 7 days of hourly points + numPoints = 168 + } + + points := s.historyStats.GetHistory(numPoints) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "points": points, + }) +} + +func (s *StatsAPI) handleClearLogs(w http.ResponseWriter, req *http.Request) { + s.ringBuffer.Clear() + s.topStats.Clear() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "message": "Logs cleared successfully", + }) +} + +func (s *StatsAPI) handleClearCache(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "message": "Cache clear request processed", + }) +} + +func (s *StatsAPI) Exec(ctx context.Context, qCtx *query_context.Context, next sequence.ChainWalker) error { + start := time.Now() + err := next.ExecNext(ctx, qCtx) + elapsed := time.Since(start) + + s.totalQueries.Add(1) + s.totalLatencyUs.Add(uint64(elapsed.Microseconds())) + + var clientIP string + if clientAddr := qCtx.ServerMeta.ClientAddr; clientAddr.IsValid() { + clientIP = clientAddr.String() + } + if clientIP == "" { + clientIP = "127.0.0.1" + } + + qQuestion := qCtx.QQuestion() + domain := qQuestion.Name + qtypeStr := dns.TypeToString[qQuestion.Qtype] + if qtypeStr == "" { + qtypeStr = fmt.Sprintf("TYPE%d", qQuestion.Qtype) + } + + isCached := qCtx.CacheState.Hit || qCtx.CacheState.LazyHit + if isCached { + s.cachedQueries.Add(1) + } + + r := qCtx.R() + var status string + var answers []AnswerDTO + isBlocked := false + + if r == nil { + status = "DROPPED" + isBlocked = true + } else { + if rcodeStr, ok := dns.RcodeToString[r.Rcode]; ok { + status = rcodeStr + } else { + status = fmt.Sprintf("RCODE%d", r.Rcode) + } + + if r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused { + isBlocked = true + } + + for _, rr := range r.Answer { + hdr := rr.Header() + rrTypeStr := dns.TypeToString[hdr.Rrtype] + if rrTypeStr == "" { + rrTypeStr = fmt.Sprintf("TYPE%d", hdr.Rrtype) + } + var dataStr string + switch record := rr.(type) { + case *dns.A: + dataStr = record.A.String() + if dataStr == "0.0.0.0" || dataStr == "127.0.0.1" { + isBlocked = true + } + case *dns.AAAA: + dataStr = record.AAAA.String() + if dataStr == "::" || dataStr == "::1" { + isBlocked = true + } + case *dns.CNAME: + dataStr = record.Target + case *dns.TXT: + dataStr = strings.Join(record.Txt, " ") + case *dns.PTR: + dataStr = record.Ptr + case *dns.MX: + dataStr = record.Mx + default: + dataStr = rr.String() + } + answers = append(answers, AnswerDTO{ + Type: rrTypeStr, + Data: dataStr, + TTL: hdr.Ttl, + }) + } + } + + if isBlocked { + s.blockedQueries.Add(1) + } + + // Extract Upstream information + var upstream string + if isCached { + upstream = "cache" + } else if u := qCtx.UpstreamSelected; u != nil { + if u.Protocol != "" && u.Addr != "" { + upstream = fmt.Sprintf("%s://%s", u.Protocol, u.Addr) + } else if u.Addr != "" { + upstream = u.Addr + } else if u.Tag != "" { + upstream = u.Tag + } + } + + // Extract Rule information + var rule string + if len(qCtx.RuleHits) > 0 { + for i := len(qCtx.RuleHits) - 1; i >= 0; i-- { + hit := qCtx.RuleHits[i] + if len(hit.Matches) > 0 { + rule = strings.Join(hit.Matches, ",") + break + } else if hit.Exec != "" { + rule = hit.Exec + break + } else if hit.Sequence != "" { + rule = hit.Sequence + break + } + } + } + + s.topStats.Record(domain, clientIP, isBlocked) + s.historyStats.Record(start, isBlocked, isCached) + + elapsedMS := math.Round(float64(elapsed.Microseconds())/10.0) / 100.0 + + entry := LogEntry{ + Timestamp: start.UTC().Format(time.RFC3339), + ClientIP: clientIP, + Domain: domain, + QType: qtypeStr, + Status: status, + IsBlocked: isBlocked, + IsCached: isCached, + ElapsedMS: elapsedMS, + Upstream: upstream, + Rule: rule, + Answers: answers, + } + + s.ringBuffer.Push(entry) + + return err +} + +func (s *StatsAPI) Close() error { + s.closeOnce.Do(func() { + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.httpServer.Shutdown(ctx) + } + }) + return nil +} --- /dev/null +++ b/plugin/executable/stats_api/stats_api_test.go @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2020-2026, IrineSistiana + * + * This file is part of mosdns. + * + * mosdns is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * mosdns is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package stats_api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/IrineSistiana/mosdns/v5/pkg/query_context" + "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence" + "github.com/miekg/dns" + "go.uber.org/zap" +) + +func TestRingBuffer(t *testing.T) { + rb := NewRingBuffer(5) + for i := 1; i <= 10; i++ { + rb.Push(LogEntry{ + Domain: fmt.Sprintf("example%d.com.", i), + ClientIP: "192.168.1.1", + IsBlocked: i%2 == 0, + IsCached: i%3 == 0, + }) + } + + total, logs := rb.QueryLogs(10, 0, "", "all") + if total != 5 { + t.Fatalf("expected total 5, got %d", total) + } + if len(logs) != 5 { + t.Fatalf("expected 5 logs, got %d", len(logs)) + } + + if logs[0].Domain != "example10.com." { + t.Errorf("expected newest domain example10.com., got %s", logs[0].Domain) + } + + totalSearch, searchLogs := rb.QueryLogs(10, 0, "example9", "all") + if totalSearch != 1 || searchLogs[0].Domain != "example9.com." { + t.Errorf("search failed, total: %d", totalSearch) + } + + totalBlocked, blockedLogs := rb.QueryLogs(10, 0, "", "blocked") + if totalBlocked != 3 { + t.Errorf("expected 3 blocked logs, got %d", totalBlocked) + } + for _, l := range blockedLogs { + if !l.IsBlocked { + t.Errorf("expected blocked log, got unblocked: %s", l.Domain) + } + } + + _, pageLogs := rb.QueryLogs(2, 1, "", "all") + if len(pageLogs) != 2 { + t.Fatalf("expected 2 page items, got %d", len(pageLogs)) + } + if pageLogs[0].Domain != logs[1].Domain { + t.Errorf("pagination offset mismatch: got %s, expected %s", pageLogs[0].Domain, logs[1].Domain) + } + + // Test Clear + rb.Clear() + totalClear, logsClear := rb.QueryLogs(10, 0, "", "all") + if totalClear != 0 || len(logsClear) != 0 { + t.Errorf("expected 0 logs after clear, got %d", totalClear) + } +} + +func TestTopStats(t *testing.T) { + top := NewTopStats() + + top.Record("a.com.", "192.168.1.1", false) + top.Record("a.com.", "192.168.1.1", true) + top.Record("b.com.", "192.168.1.2", true) + top.Record("a.com.", "192.168.1.2", false) + + domains, clients, blocked := top.GetTop(10) + + if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 3 { + t.Errorf("top domains mismatch: %+v", domains) + } + + if len(clients) < 2 { + t.Fatalf("expected at least 2 clients, got %d", len(clients)) + } + + if len(blocked) < 2 { + t.Fatalf("expected at least 2 blocked domains, got %d", len(blocked)) + } + + // Test Clear + top.Clear() + dClear, _, _ := top.GetTop(10) + if len(dClear) != 0 { + t.Errorf("expected 0 top domains after clear, got %d", len(dClear)) + } +} + +func TestHistoryStats(t *testing.T) { + h := NewHistoryStats() + now := time.Now() + + h.Record(now, false, false) + h.Record(now, true, false) + h.Record(now, false, true) + + points := h.GetHistory(24) + if len(points) != 24 { + t.Fatalf("expected 24 history points, got %d", len(points)) + } + + lastPoint := points[len(points)-1] + if lastPoint.Total != 3 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 { + t.Errorf("history point mismatch: %+v", lastPoint) + } +} + +func TestStatsAPIHTTPEndpoints(t *testing.T) { + s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop()) + router := s.Router() + + s.ringBuffer.Push(LogEntry{ + Domain: "test.com.", + ClientIP: "192.168.1.50", + IsBlocked: true, + IsCached: false, + ElapsedMS: 15.5, + Upstream: "UDP://8.8.8.8:53", + Rule: "qname test.com.", + }) + s.totalQueries.Add(1) + s.blockedQueries.Add(1) + s.totalLatencyUs.Add(15500) + s.topStats.Record("test.com.", "192.168.1.50", true) + s.historyStats.Record(time.Now(), true, false) + + // Test GET /api/v1/stats + reqStats := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + wStats := httptest.NewRecorder() + router.ServeHTTP(wStats, reqStats) + + if wStats.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", wStats.Code) + } + + var statsResp map[string]any + if err := json.Unmarshal(wStats.Body.Bytes(), &statsResp); err != nil { + t.Fatalf("failed to unmarshal stats response: %v", err) + } + if statsResp["total_queries"].(float64) != 1 { + t.Errorf("expected total_queries 1, got %v", statsResp["total_queries"]) + } + + // Test GET /api/v1/history + reqHist := httptest.NewRequest(http.MethodGet, "/api/v1/history?points=24", nil) + wHist := httptest.NewRecorder() + router.ServeHTTP(wHist, reqHist) + + if wHist.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for history, got %d", wHist.Code) + } + var histResp map[string]any + if err := json.Unmarshal(wHist.Body.Bytes(), &histResp); err != nil { + t.Fatalf("failed to unmarshal history response: %v", err) + } + histPoints := histResp["points"].([]any) + if len(histPoints) != 24 { + t.Errorf("expected 24 history points, got %d", len(histPoints)) + } + + // Test POST /api/v1/logs/clear + reqClearLogs := httptest.NewRequest(http.MethodPost, "/api/v1/logs/clear", nil) + wClearLogs := httptest.NewRecorder() + router.ServeHTTP(wClearLogs, reqClearLogs) + + if wClearLogs.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for clear logs, got %d", wClearLogs.Code) + } + + // Verify logs ring buffer is empty + totalLogs, logs := s.ringBuffer.QueryLogs(10, 0, "", "all") + if totalLogs != 0 || len(logs) != 0 { + t.Errorf("expected 0 logs after clear, got %d", totalLogs) + } + + // Test POST /api/v1/cache/clear + reqClearCache := httptest.NewRequest(http.MethodPost, "/api/v1/cache/clear", nil) + wClearCache := httptest.NewRecorder() + router.ServeHTTP(wClearCache, reqClearCache) + + if wClearCache.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for clear cache, got %d", wClearCache.Code) + } +} + +func TestStatsAPIExec(t *testing.T) { + s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop()) + + q := new(dns.Msg) + q.SetQuestion("google.com.", dns.TypeA) + qCtx := query_context.NewContext(q) + qCtx.SetUpstreamSelected("8.8.8.8:53", "UDP", "remote", "forward_remote") + qCtx.AddRuleHit("main_sequence", []string{"qname google.com."}, "forward_remote") + + execFunc := sequence.ExecutableFunc(func(ctx context.Context, qCtx *query_context.Context) error { + time.Sleep(10 * time.Millisecond) + resp := new(dns.Msg) + resp.SetReply(qCtx.Q()) + resp.Answer = append(resp.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: "google.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, + A: []byte{142, 250, 190, 46}, + }) + qCtx.SetResponse(resp) + return nil + }) + + walker := sequence.NewChainWalker([]*sequence.ChainNode{ + {E: execFunc}, + }, nil) + + err := s.Exec(context.Background(), qCtx, walker) + if err != nil { + t.Fatalf("Exec returned error: %v", err) + } + + if s.totalQueries.Load() != 1 { + t.Errorf("expected 1 total query, got %d", s.totalQueries.Load()) + } + + totalLogs, logs := s.ringBuffer.QueryLogs(10, 0, "", "all") + if totalLogs != 1 { + t.Fatalf("expected 1 log entry, got %d", totalLogs) + } + if logs[0].Domain != "google.com." { + t.Errorf("expected domain google.com., got %s", logs[0].Domain) + } + if logs[0].Upstream != "UDP://8.8.8.8:53" { + t.Errorf("expected upstream UDP://8.8.8.8:53, got %s", logs[0].Upstream) + } + if logs[0].Rule != "qname google.com." { + t.Errorf("expected rule qname google.com., got %s", logs[0].Rule) + } +}