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:
1 parent
4113d5d6fc
commit
403f85486d
14 files changed
+216
-15
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user