Files
op-packages/mosdns/patches/216-plugin-add-stats_api-plugin.patch
T

1022 lines
26 KiB
Diff

From 1c53fecde36c06f87d62860e11aee92b9c570d75 Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Fri, 28 Aug 2026 19:06:24 +0800
Subject: [PATCH 1/5] plugin: add stats_api plugin
Signed-off-by: sbwml <admin@cooluc.com>
---
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 <https://www.gnu.org/licenses/>.
+ */
+
+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 <https://www.gnu.org/licenses/>.
+ */
+
+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)
+ }
+}