jarl block pause|resume (and the Blocking page) lets every name through for a while; jarld resumes on time. The lists stay loaded: the rules zone just passes everything. Statistics gain the clients with the most blocked queries and blocked queries on the queries chart. The view choice in the blocking forms is a dropdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
5.9 KiB
Go
243 lines
5.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"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
|
|
}
|