Mark signed zones with a padlock, and name clients in statistics

The zones list shows a padlock after signed zones, linking to their
DNSSEC tab. Client lists in the statistics show the host name Jarl's
records have for each address (PTR, else A/AAAA); no DNS queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-09 23:27:53 +00:00
1 parent 4113d5d6fc
commit 403f85486d
14 files changed
+216 -15

No files matched your search

+38
View File
@@ -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)
}
}
}
+95
View File
@@ -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
}
+11
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+6 -6
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+3
View File
@@ -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.
+4 -2
View File
@@ -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) + "%" } })));
}));
+19 -1
View File
@@ -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 = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="' +
(off ? "M5 7V5a3 3 0 0 1 5.8-1.1" : "M5 7V5a3 3 0 0 1 6 0v2") +
'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="7" width="10" height="7.5" rx="1.5" fill="currentColor"/></svg>';
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");
}
+2
View File
@@ -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); }