Files
jarl/internal/server/h_stats.go
T
adamandClaude Opus 5.5 12820b17bb Statistics per view: toggle views on the Statistics page
Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00

289 lines
7.8 KiB
Go

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
}