Files
jarl/internal/server/h_stats.go
T
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +01:00

355 lines
9.9 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.NameLess(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)
// The statistics database: what the statistics cost on disk.
if db := s.cfg.Stats.Store().Path(); db != "" && db != ":memory:" {
info := map[string]any{"path": db, "bytes": dbBytes(db)}
if keep := s.cfg.Stats.Options().RetainQueries; keep > 0 && s.cfg.Stats.Options().QueryLog {
if n, err := s.cfg.Stats.Store().QueryLogSize(r.Context()); err == nil {
info["query_log_rows"] = n
}
}
out["database"] = info
}
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
}