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
173 lines
6.6 KiB
Go
173 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|