Publish DHCP clients' names in DNS

router-webui leases-dns reads Kea's leases and publishes <name>.<domain>
with reverse records in the running Unbound (router.dns.dynamicLeases).
Names the configuration publishes are never taken over; a client's name
is cut to its first label and kept inside the local domain; of two
clients the newer lease wins. The DNS page switches it on and shows what
is published and what was skipped; router-setup enables it with a domain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-30 00:18:01 +01:00
co-authored by Claude Sonnet 5.5
parent 84f6002d05
commit 2eab735f59
13 changed files with 711 additions and 7 deletions
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"errors"
"slices"
"strings"
"testing"
"time"
)
var t0 = time.Unix(1_800_000_000, 0)
func lease(host, addr string, startAgo time.Duration) Lease {
return Lease{Hostname: host, Address: addr, Start: t0.Add(-startAgo), Expires: t0.Add(time.Hour - startAgo)}
}
var testStatic = staticNames{
Domain: "home.example.org",
Names: []string{"router.home.example.org", "nas.home.example.org"},
Overrides: []string{"apps.home.example.org"},
ForwardZones: []string{"branch.home.example.org"},
}
func TestPlanDynamic(t *testing.T) {
leases := []Lease{
lease("Phone", "192.168.1.101", time.Minute),
lease("laptop.evil.example", "192.168.1.102", time.Minute), // only the first label
lease("nas", "192.168.1.103", time.Minute), // a static name
lease("router", "192.168.1.104", time.Minute), // the router itself
lease("web.apps", "192.168.1.105", time.Minute), // first label "web": fine
lease("x_y", "192.168.1.106", time.Minute), // not a host name
lease("", "192.168.1.107", time.Minute), // none sent
lease("tv", "192.168.1.108", 2*time.Hour), // expired (started 2h ago)
lease("printer", "192.168.1.110", 10*time.Minute),
lease("printer", "192.168.1.111", time.Minute), // newer: wins
}
want, skipped := planDynamic(leases, testStatic, t0)
var names []string
for n := range want {
names = append(names, n)
}
slices.Sort(names)
if strings.Join(names, " ") != "laptop.home.example.org phone.home.example.org printer.home.example.org web.home.example.org" {
t.Errorf("published = %v", names)
}
if want["printer.home.example.org"].Address != "192.168.1.111" {
t.Errorf("the newer lease did not win: %+v", want["printer.home.example.org"])
}
reasons := map[string]string{}
for _, s := range skipped {
reasons[s.Address] = s.Reason
}
for addr, part := range map[string]string{"192.168.1.103": "configuration", "192.168.1.104": "configuration",
"192.168.1.106": "not a usable", "192.168.1.110": "newer lease"} {
if !strings.Contains(reasons[addr], part) {
t.Errorf("%s: skipped for %q, want %q", addr, reasons[addr], part)
}
}
// Names below a wildcard or a forward zone are not taken.
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "apps.home.example.org", Overrides: []string{"apps.home.example.org"}}, t0)
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "wildcard") {
t.Errorf("wildcard: %+v", skipped)
}
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "b.example.org", ForwardZones: []string{"b.example.org"}}, t0)
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "forwarded") {
t.Errorf("forward zone: %+v", skipped)
}
// Without a domain nothing is published.
if w, _ := planDynamic(leases, staticNames{}, t0); len(w) != 0 {
t.Error("published without a domain")
}
// Not IPv4.
if w, s := planDynamic([]Lease{lease("v6", "fd00::1", 0)}, testStatic, t0); len(w) != 0 || len(s) != 1 {
t.Errorf("IPv6 lease: %v %v", w, s)
}
}
func TestReverseName(t *testing.T) {
if got := reverseName("192.168.1.101"); got != "101.1.168.192.in-addr.arpa" {
t.Errorf("reverse = %q", got)
}
if reverseName("fd00::1") != "" || reverseName("x") != "" {
t.Error("a non-IPv4 address has no reverse name here")
}
}
type fakeCtl struct {
cmds []string
fail string
}
func (f *fakeCtl) run(args ...string) (string, error) {
cmd := strings.Join(args, " ")
f.cmds = append(f.cmds, cmd)
if f.fail != "" && strings.Contains(cmd, f.fail) {
return "", errors.New("boom")
}
return "ok", nil
}
func TestSyncDynamic(t *testing.T) {
a := DynRecord{Name: "a.home.example.org", Address: "192.168.1.101"}
b := DynRecord{Name: "b.home.example.org", Address: "192.168.1.102"}
ctl := &fakeCtl{}
got, err := syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
if err != nil || len(got) != 2 {
t.Fatalf("first sync: %v %v", got, err)
}
if !slices.Contains(ctl.cmds, "local_data a.home.example.org. 300 IN A 192.168.1.101") ||
!slices.Contains(ctl.cmds, "local_data 101.1.168.192.in-addr.arpa. 300 IN PTR a.home.example.org.") {
t.Errorf("commands = %v", ctl.cmds)
}
// Nothing changed: nothing is sent.
ctl.cmds = nil
if _, err := syncDynamic(ctl, got, map[string]DynRecord{a.Name: a, b.Name: b}, false); err != nil || len(ctl.cmds) != 0 {
t.Errorf("no change sent %v (%v)", ctl.cmds, err)
}
// b went away, a moved: b is removed, a replaced.
a2 := DynRecord{Name: a.Name, Address: "192.168.1.150"}
got, err = syncDynamic(ctl, got, map[string]DynRecord{a.Name: a2}, false)
if err != nil || len(got) != 1 || got[a.Name].Address != "192.168.1.150" {
t.Fatalf("second sync: %v %v", got, err)
}
for _, want := range []string{"local_data_remove b.home.example.org.", "local_data_remove 102.1.168.192.in-addr.arpa.",
"local_data_remove a.home.example.org.", "local_data a.home.example.org. 300 IN A 192.168.1.150"} {
if !slices.Contains(ctl.cmds, want) {
t.Errorf("missing %q in %v", want, ctl.cmds)
}
}
// A fresh Unbound (or daemon): everything is removed, then added again.
ctl.cmds = nil
if _, err := syncDynamic(ctl, got, got, true); err != nil || len(ctl.cmds) != 4 {
t.Errorf("fresh sync: %v %v", ctl.cmds, err)
}
// A failing add is not recorded as published, so it is retried.
ctl = &fakeCtl{fail: "local_data a."}
got, err = syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
if err == nil || len(got) != 1 || got[b.Name].Name == "" {
t.Errorf("failing add: %v %v", got, err)
}
// Stopping removes everything.
ctl = &fakeCtl{}
if got, _ := syncDynamic(ctl, map[string]DynRecord{a.Name: a, b.Name: b}, nil, false); len(got) != 0 || len(ctl.cmds) != 4 {
t.Errorf("stop: %v %v", got, ctl.cmds)
}
}
func TestParseUnboundPID(t *testing.T) {
out := "version: 1.26.1\nverbosity: 1\nthreads: 1\nuptime: 0 seconds\nunbound (pid 1458081) is running...\n"
if pid, err := parseUnboundPID(out); err != nil || pid != "1458081" {
t.Errorf("pid = %q %v", pid, err)
}
if _, err := parseUnboundPID("error: connection refused"); err == nil {
t.Error("a pid out of nothing")
}
}
func TestLeaseStartIsRead(t *testing.T) {
csv := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n" +
"192.168.1.101,aa:bb:cc:00:00:01,,3600,4102444800,1,0,0,phone,0,,0\n"
latest := map[string]Lease{}
parseLeases(strings.NewReader(csv), latest, time.Unix(1_800_000_000, 0))
l := latest["192.168.1.101"]
if l.Expires.Unix() != 4102444800 || l.Start.Unix() != 4102444800-3600 {
t.Errorf("lease = %+v", l)
}
}