Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
7.3 KiB
Go
275 lines
7.3 KiB
Go
package server
|
|
|
|
import (
|
|
"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.
|
|
func (s *Server) statsQuerier(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)"}
|
|
}
|
|
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(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(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(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(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(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 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(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
|
|
}
|