diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f6cda..f0c921f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,14 @@ All notable changes to Jarl are documented here. The format follows and `list` prints the IDs too. With a change (`jarl record add ... -o id`) they apply to the changed object. +- Statistics: client lists (busiest and refused clients, in the web UI, + `jarl stats top clients|refused` and `jarl stats summary`) show the host + name Jarl's records have for each address: the PTR record's target, or + else the name of an A/AAAA record. Looked up in Jarl's own data, so no + DNS queries; an address without a record shows as before. +- Zones list: a padlock after each signed zone's name, linking to its + DNSSEC tab (open and amber while signing is being turned off). + ### Changed - The web UI and API can listen on ports below 1024, such as 443: diff --git a/cmd/jarl/stats.go b/cmd/jarl/stats.go index 8cf3bc8..d82b33d 100644 --- a/cmd/jarl/stats.go +++ b/cmd/jarl/stats.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "math" + "slices" "strconv" "strings" "time" @@ -109,7 +110,11 @@ export the data.`, } fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k]) for _, e := range es { - fmt.Fprintf(w, " %s\t%d\n", e.Key, e.Count) + key := e.Key + if e.Name != "" { + key += " (" + e.Name + ")" + } + fmt.Fprintf(w, " %s\t%d\n", key, e.Count) } } }) @@ -152,9 +157,18 @@ export the data.`, if head == "" { head = "NAME" } - fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head) + named := slices.ContainsFunc(es, func(e stats.Entry) bool { return e.Name != "" }) + if named { + fmt.Fprintf(w, "#\t%s\tHOST\tQUERIES\t\n", head) + } else { + fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head) + } for i, e := range es { - fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20)) + if named { + fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%s\n", i+1, e.Key, dash(e.Name), e.Count, bar(e.Count, es[0].Count, 20)) + } else { + fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20)) + } } }) }, diff --git a/docs/api.md b/docs/api.md index cead89b..66723e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -486,7 +486,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but | GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` | | GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` | | GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` | -| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]` | +| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]`; client and refused entries add `name`, the host name of Jarl's record for the address (the summary's client list too) | | GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` | diff --git a/docs/stats.md b/docs/stats.md index 94c3d66..ca2dae9 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -111,6 +111,16 @@ Nothing else is stored. In particular there's no per-query record, so you can't look up "who asked for X at 14:02". That is deliberate, both for size and privacy. +**Client names.** Where the client lists (busiest and refused clients) show +an address Jarl has a record for, they show its host name too: the PTR +record's target, or else the name of an A/AAAA record with that address +(disabled records don't count, and only records you may read). This is +looked up in Jarl's own data when the list is shown, not stored and not +asked of DNS, so it is instant and adds no queries of its own. The name is +what the records say now: with DHCP, an address seen last week may have +been another machine. Anonymized clients (`anonymize_clients`) are +prefixes and get no name. + ## Using it **Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the diff --git a/internal/server/clientnames_test.go b/internal/server/clientnames_test.go new file mode 100644 index 0000000..4f0e01b --- /dev/null +++ b/internal/server/clientnames_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/netip" + "testing" + + "jarl/internal/model" +) + +func TestClientNames(t *testing.T) { + st := model.NewState() + add := func(id int64, name, typ, data string, disabled bool) { + st.Records[id] = &model.Record{ID: id, ZoneID: 1, Name: name, Type: typ, Data: data, Disabled: disabled} + } + add(1, "nas.adbraun.lol.", "A", "192.168.1.20", false) + add(2, "20.1.168.192.in-addr.arpa.", "PTR", "storage.adbraun.lol.", false) // PTR wins over A + add(3, "web.adbraun.lol.", "A", "192.168.1.30", false) // no PTR: the A record's name + add(4, "alias.adbraun.lol.", "A", "192.168.1.30", false) // first in name order wins + add(5, "old.adbraun.lol.", "A", "192.168.1.40", true) // disabled: ignored + add(6, "secret.adbraun.lol.", "A", "192.168.1.50", false) // not readable + add(7, "v6.adbraun.lol.", "AAAA", "2001:db8::7", false) + add(8, "printer.adbraun.lol.", "A", "192.168.1.60", false) // not asked for + + want := map[netip.Addr]bool{} + for _, a := range []string{"192.168.1.20", "192.168.1.30", "192.168.1.40", "192.168.1.50", "2001:db8::7", "10.0.0.1"} { + want[netip.MustParseAddr(a)] = true + } + got := clientNames(st, want, func(r *model.Record) bool { return r.ID != 6 }) + exp := map[string]string{"192.168.1.20": "storage.adbraun.lol", "192.168.1.30": "alias.adbraun.lol", "2001:db8::7": "v6.adbraun.lol"} + if len(got) != len(exp) { + t.Errorf("got %v", got) + } + for a, n := range exp { + if got[netip.MustParseAddr(a)] != n { + t.Errorf("%s = %q, want %q", a, got[netip.MustParseAddr(a)], n) + } + } +} diff --git a/internal/server/h_stats.go b/internal/server/h_stats.go index 7a95029..6a16b0b 100644 --- a/internal/server/h_stats.go +++ b/internal/server/h_stats.go @@ -2,10 +2,14 @@ package server import ( "net/http" + "net/netip" + "slices" "strings" "time" "jarl/internal/auth" + "jarl/internal/dnsname" + "jarl/internal/model" "jarl/internal/stats" ) @@ -53,6 +57,9 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr 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 } @@ -75,6 +82,11 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi if err != nil { return badRequest("%v", err) } + if kind == stats.KindClient || kind == stats.KindRefused { + if err := s.nameClients(r, p, es); err != nil { + return err + } + } writeJSON(w, http.StatusOK, es) return nil } @@ -145,3 +157,86 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth. 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 +} diff --git a/internal/server/stats_test.go b/internal/server/stats_test.go index 8f59366..685ab9a 100644 --- a/internal/server/stats_test.go +++ b/internal/server/stats_test.go @@ -58,6 +58,17 @@ func TestStatsAPI(t *testing.T) { if len(tops) != 2 || tops[0]["key"] != "www.example.com" { t.Errorf("top names = %v", tops) } + // Clients get the host name of Jarl's record for their address. + if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != nil { + t.Errorf("unnamed client = %v", c) + } + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201) + if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != "laptop.example.com" { + t.Errorf("named client = %v", c) + } + if sum := e.do("GET", "/stats/summary?since=2h", e.token, nil, 200); sum["tops"].(map[string]any)["client"].([]any)[0].(map[string]any)["name"] != "laptop.example.com" { + t.Errorf("summary clients = %v", sum["tops"]) + } if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" { t.Errorf("zones = %v", b) } diff --git a/internal/stats/collector.go b/internal/stats/collector.go index a3f52f4..666bc8b 100644 --- a/internal/stats/collector.go +++ b/internal/stats/collector.go @@ -528,7 +528,7 @@ func (c *Collector) updateWarnings() { func top(m map[string]int64, n int) []Entry { out := make([]Entry, 0, len(m)) for k, v := range m { - out = append(out, Entry{k, v}) + out = append(out, Entry{Key: k, Count: v}) } slices.SortFunc(out, func(a, b Entry) int { if a.Count != b.Count { diff --git a/internal/stats/collector_test.go b/internal/stats/collector_test.go index 0f7e36f..1c8d75b 100644 --- a/internal/stats/collector_test.go +++ b/internal/stats/collector_test.go @@ -118,7 +118,7 @@ func TestCollector(t *testing.T) { } } names, _ := store.TopsAt(ctx, ResHour, m, KindName) - if len(names) != 3 || names[0] != (Entry{"nas.adbraun.lol", 2}) { + if len(names) != 3 || names[0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) { t.Errorf("hour names = %v", names) } if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" { @@ -133,7 +133,7 @@ func TestCollector(t *testing.T) { clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc) c.flush(ctx, false) day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix() - if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{"nas.adbraun.lol", 3}) { + if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{Key: "nas.adbraun.lol", Count: 3}) { t.Errorf("day names after the hour = %v", d) } if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 { @@ -149,14 +149,14 @@ func TestCollector(t *testing.T) { c2.readLog() clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc) c2.flush(ctx, false) - if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{"nas.adbraun.lol", 4}) { + if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) { t.Errorf("hour 10 after late line = %v", h) } - if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{"nas.adbraun.lol", 4}) { + if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) { t.Errorf("day after late line = %v", d) } h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix() - if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{"printer.adbraun.lol", 2}) { + if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{Key: "printer.adbraun.lol", Count: 2}) { t.Errorf("hour 11 continued after restart = %v", h) } @@ -198,7 +198,7 @@ func TestCollector(t *testing.T) { t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3]) } b, _ := q.Breakdown(ctx, "zone", 24*time.Hour) - if len(b) != 2 || b[0] != (Entry{"adbraun.lol", 7}) || b[1] != (Entry{OtherZone, 1}) { + if len(b) != 2 || b[0] != (Entry{Key: "adbraun.lol", Count: 7}) || b[1] != (Entry{Key: OtherZone, Count: 1}) { t.Errorf("zone breakdown = %v", b) } diff --git a/internal/stats/query.go b/internal/stats/query.go index adf6c40..1b2bf1c 100644 --- a/internal/stats/query.go +++ b/internal/stats/query.go @@ -128,7 +128,7 @@ func sorted(m map[string]int64, prefix string) []Entry { out := []Entry{} for k, v := range m { if strings.HasPrefix(k, prefix) && v != 0 { - out = append(out, Entry{strings.TrimPrefix(k, prefix), v}) + out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v}) } } slices.SortFunc(out, func(a, b Entry) int { diff --git a/internal/stats/store.go b/internal/stats/store.go index 3436806..f769abe 100644 --- a/internal/stats/store.go +++ b/internal/stats/store.go @@ -135,6 +135,9 @@ type topsWrite struct { type Entry struct { Key string `json:"key"` Count int64 `json:"count"` + // Name is a client's host name, from Jarl's own records (set by the + // API for client lists). + Name string `json:"name,omitempty"` } // AddCounters adds values to the counters of a bucket. diff --git a/internal/web/static/assets/charts.js b/internal/web/static/assets/charts.js index 41643ed..83fb50c 100644 --- a/internal/web/static/assets/charts.js +++ b/internal/web/static/assets/charts.js @@ -324,9 +324,11 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet const share = sum ? (e.count / sum) * 100 : 0; const name = label(e.key); const link = href ? href(e.key) : null; - return h("li", { title: name + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, + // Clients Jarl has a host name for: the name, then the address. + const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name; + return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, h("div", { class: "bar-text" }, - link ? h("a", { href: link, class: "bar-label" }, name) : h("span", { class: "bar-label" }, name), + link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown), h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " ยท " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); })); diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index 39b79b1..9baf949 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -23,7 +23,7 @@ export async function render(el, params, query) { lastList.zones = hash; const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value)); mount(list, table([ - { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, + { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null, { label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") }, { label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" }, @@ -236,6 +236,24 @@ export function emailOf(rname) { return m[1].replace(/\\\./g, ".") + "@" + m[2]; } +// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab: +// closed while signed, open while signing is being turned off (BIND's +// insecure policy). +function dnssecLock(z) { + if (!z.dnssec_policy) return null; + const off = z.dnssec_policy === "insecure"; + const a = h("a", { + class: "dnssec-lock" + (off ? " off" : ""), + href: "#/zones/" + z.id + "/dnssec", + title: off ? "DNSSEC: signing is being turned off" : "DNSSEC: signed (policy " + z.dnssec_policy + ")", + "aria-label": off ? "DNSSEC being turned off" : "DNSSEC signed", + }); + a.innerHTML = ''; + return a; +} + export function pendingBadge(kind) { return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn"); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 9640d6e..08b2203 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -141,6 +141,8 @@ th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: upp tr:hover td { background: var(--info-soft); } td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; } td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; } +a.dnssec-lock { display: inline-flex; vertical-align: -1px; margin-left: 6px; color: var(--ok); text-decoration: none; } +a.dnssec-lock.off { color: var(--warn); } tr.same-name-next td { border-bottom-color: color-mix(in srgb, var(--border) 40%, transparent); } td.actions-cell { white-space: nowrap; text-align: right; width: 1%; } tr.editing td { background: var(--accent-soft); }