package server import ( "errors" "net/http" "net/netip" "path/filepath" "slices" "strings" "time" "jarl/internal/auth" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/stats" ) // statsQuerier checks permission and that statistics are enabled. With // ?views=internal,vps the figures are for those views only. func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) { if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil { return nil, err } if s.statsQ == nil { return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"} } var views []string for _, v := range strings.Split(r.URL.Query().Get("views"), ",") { if v = strings.TrimSpace(v); v != "" { views = append(views, v) } } if len(views) > 0 { return s.statsQ.ForViews(views), nil } return s.statsQ, nil } func statsSince(r *http.Request) (time.Duration, error) { d, err := stats.ParseSince(r.URL.Query().Get("since")) if err != nil { return 0, badRequest("%v", err) } return d, nil } func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil { return err } if s.cfg.Stats == nil { writeJSON(w, http.StatusOK, stats.Status{Enabled: false, Warnings: []string{}}) return nil } writeJSON(w, http.StatusOK, s.cfg.Stats.Status()) return nil } func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since, err := statsSince(r) if err != nil { return err } sum, err := q.Summary(r.Context(), since, queryInt(r, "top", 10)) if err != nil { return err } if err := s.nameClients(r, p, sum.Tops[stats.KindClient]); err != nil { return err } writeJSON(w, http.StatusOK, sum) return nil } func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since, err := statsSince(r) if err != nil { return err } kind := r.URL.Query().Get("kind") if kind == "" { kind = stats.KindName } limit := min(max(queryInt(r, "limit", 20), 1), 10000) es, err := q.Top(r.Context(), kind, since, limit) if err != nil { return badRequest("%v", err) } if kind == stats.KindClient || kind == stats.KindRefused || kind == stats.KindBlockedClient { if err := s.nameClients(r, p, es); err != nil { return err } } writeJSON(w, http.StatusOK, es) return nil } func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since, err := statsSince(r) if err != nil { return err } var keys []string for _, k := range r.URL.Query()["key"] { for _, part := range strings.Split(k, ",") { if part = strings.TrimSpace(part); part != "" { keys = append(keys, part) } } } if len(keys) == 0 { keys = []string{"queries"} } if len(keys) > 50 { return badRequest("at most 50 series") } tl, err := q.Timeline(r.Context(), since, r.URL.Query().Get("step"), keys) if err != nil { return badRequest("%v", err) } writeJSON(w, http.StatusOK, tl) return nil } func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since := 28 * 24 * time.Hour if r.URL.Query().Get("since") != "" { if since, err = statsSince(r); err != nil { return err } } hm, err := q.Heatmap(r.Context(), r.URL.Query().Get("key"), since) if err != nil { return err } writeJSON(w, http.StatusOK, hm) return nil } func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since, err := statsSince(r) if err != nil { return err } es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since) if errors.Is(err, stats.ErrNotPerView) { return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()} } if err != nil { return badRequest("%v", err) } writeJSON(w, http.StatusOK, es) return nil } // nameClients gives client entries the host name Jarl's records have for // the address: the PTR record's target, or else the name of an A/AAAA // record with the address. It sends no DNS queries, and uses only records // the user may read. Anonymized clients (prefixes) get no name. func (s *Server) nameClients(r *http.Request, p *auth.Principal, es []stats.Entry) error { if len(es) == 0 { return nil } want := map[netip.Addr]bool{} for _, e := range es { if a, err := netip.ParseAddr(e.Key); err == nil { want[a.Unmap()] = true } } if len(want) == 0 { return nil } st, err := s.state(r) if err != nil { return err } names := clientNames(st, want, func(rec *model.Record) bool { return canReadRecord(p, st, rec) }) for i, e := range es { if a, err := netip.ParseAddr(e.Key); err == nil { es[i].Name = names[a.Unmap()] } } return nil } // clientNames finds names for the addresses in want (see nameClients). // Where several records match, the first in name order wins. func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model.Record) bool) map[netip.Addr]string { ptr, host := map[netip.Addr]string{}, map[netip.Addr]string{} var recs []*model.Record for _, rec := range st.Records { if !rec.Disabled && (rec.Type == "PTR" || rec.Type == "A" || rec.Type == "AAAA") { recs = append(recs, rec) } } slices.SortFunc(recs, func(a, b *model.Record) int { if a.Name != b.Name { if model.CanonicalLess(a.Name, b.Name) { return -1 } return 1 } return strings.Compare(a.Data, b.Data) }) for _, rec := range recs { var a netip.Addr var name string if rec.Type == "PTR" { var ok bool if a, ok = dnsname.AddrFromReverse(rec.Name); !ok { continue } name = rec.Data } else { var err error if a, err = netip.ParseAddr(rec.Data); err != nil { continue } name = rec.Name } a = a.Unmap() m := host if rec.Type == "PTR" { m = ptr } if !want[a] || m[a] != "" || !readable(rec) { continue } m[a] = dnsname.Display(name) } for a, n := range host { if ptr[a] == "" { ptr[a] = n } } return ptr } // statsServer: GET /stats/server is the latest server statistics (named's // memory, CPU, cache and sockets, the host), named's client counts and // limits (rndc status), and the space left on the file systems Jarl and // named write to. func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if _, err := s.statsQuerier(r, p); err != nil { return err } out := map[string]any{"server": s.cfg.Stats.Server()} if ns, err := s.eng.NamedStatus(r.Context()); err == nil { out["named"] = map[string]string{"recursive_clients": ns.RecursiveClients, "tcp_clients": ns.TCPClients, "zones": ns.Zones, "cpus": ns.CPUs, "worker_threads": ns.WorkerThreads, "last_configured": ns.LastConfigured} } else { out["named_error"] = err.Error() } paths := s.eng.Config().Paths dirs := map[string]string{"zone files": paths.ZoneDir, "Jarl's cache and blocklists": paths.CacheDir, "named.conf": paths.ConfigDir} labels := []string{"zone files", "Jarl's cache and blocklists", "named.conf"} if lf := s.cfg.Stats.Options().LogFile; lf != "" { dirs["named's logs"] = filepath.Dir(lf) labels = append(labels, "named's logs") } if db := s.cfg.Stats.Store().Path(); db != "" { dirs["statistics database"] = filepath.Dir(db) labels = append(labels, "statistics database") } out["disks"] = stats.DiskUsage(labels, dirs) writeJSON(w, http.StatusOK, out) return nil } // statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode= // &blocked=&since=24h explores the query log (docs/stats.md#exploring): // totals, a series, the top values of each dimension left open, and the // latest answers, with client host names. func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q, err := s.statsQuerier(r, p) if err != nil { return err } since, err := statsSince(r) if err != nil { return err } v := r.URL.Query() f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"), Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since} ex, err := q.Explore(r.Context(), f, 15, 100) if errors.Is(err, stats.ErrNoQueryLog) { return &httpError{http.StatusNotFound, "query_log_off", err.Error()} } if err != nil { return err } if err := s.nameClients(r, p, ex.By["client"]); err != nil { return err } // The recent answers' clients, named once each. var es []stats.Entry seen := map[string]int{} for _, row := range ex.Recent { if _, ok := seen[row.Client]; !ok { seen[row.Client] = len(es) es = append(es, stats.Entry{Key: row.Client}) } } if err := s.nameClients(r, p, es); err != nil { return err } for i := range ex.Recent { ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name } // The filter's client, named too. out := struct { *stats.Exploration ClientName string `json:"client_name,omitempty"` }{Exploration: ex} if f.Client != "" { one := []stats.Entry{{Key: f.Client}} if err := s.nameClients(r, p, one); err == nil { out.ClientName = one[0].Name } } writeJSON(w, http.StatusOK, out) return nil }